Merge branch 'desktop-improvements-0' into mship-fixes

# Conflicts:
#	apps/sim/lib/knowledge/application/documents.ts
This commit is contained in:
Siddharth Ganesan
2026-08-13 14:34:59 -07:00
278 changed files with 20130 additions and 2658 deletions
+93 -8
View File
@@ -16,12 +16,13 @@ failure (always) { "error": { "code": "...", "message": "...", "details"?:
Nothing else at the top level. No `success: true`, no bare `{ "error": "string" }`, no HTML.
That promise is worth stating as a rule because it has been broken four separate ways, each time by a route or a builder taking a shortcut that looked local:
That promise is worth stating as a rule because it has been broken five separate ways, each time by a route or a builder taking a shortcut that looked local:
- `GET /workflows?limit=1.5` returned **500**. The contract was copied from a sibling and lost its `.int()`, so a fractional limit passed validation and reached Postgres as `LIMIT 2.5`.
- A malformed JSON body returned **`{"error":"Request body must be valid JSON"}`** — a bare string. The envelope was a per-route opt-in that only 8 of 77 routes remembered.
- `GET /api/v2/nonexistent` returned a **full HTML 404 document**, because no route file matched and the request fell through to the app's global not-found page.
- Four collections returned `nextCursor` while **silently discarding** any `limit` the caller sent, because Zod strips unknown keys unless the schema is `.strict()`.
- Handing back a `nextCursor` from any timestamp-sorted list and passing it straight in returned **500**. The value was validated and bound — but bound with no SQL type, into `date_trunc`, which is overloaded, so Postgres could resolve no overload. Validation was never the missing half; the type was.
Each was one line. The rules below are the generalisations.
@@ -51,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`. |
@@ -62,7 +63,26 @@ Two of these carry real design weight:
**404 is deliberately overloaded.** A workspace the caller cannot reach answers `404 "Workspace not found"`, never 403 — a 403 would confirm the resource exists. `createV2ResourceConcealmentPolicy` does this by mapping a cross-tenant authorization failure to `v2Error('NOT_FOUND', ...)`. The rollout gate answers the same way for the same reason (`gate.ts`: "an ungated caller cannot distinguish 'not in the rollout cohort' from 'no such endpoint'"), and so does the unknown-path catch-all at `app/api/v2/[[...segments]]/route.ts` — its body is byte-identical to the gate's on purpose.
**500 is never caller-reachable.** Any input a caller can send must be rejected at the contract boundary with a 400. If you can construct a query string or body that produces a 500, that is a bug in the contract, not something to wrap in a `try`/`catch`. `v2ErrorForOrchestration` also replaces the message on an unclassified failure with a generic one, so internal detail never leaks.
**500 is never caller-reachable.** Any input a caller can send must be rejected at the contract boundary with a 400. If you can construct a query string or body that produces a 500, that is a bug in the contract, not something to wrap in a `try`/`catch`. `v2ErrorForOrchestration` also replaces the message on an unclassified failure with a generic one, so internal detail never leaks. A caller-reachable 500 has shipped three times — a fractional `limit` reaching `LIMIT 2.5`, a plain `HEAD` tripping the builder's method guard, and a keyset cursor's timestamp reaching `date_trunc` as an untyped placeholder — so treat "a well-formed request produced a 500" as the highest-severity class of defect on this surface.
**Validating a value is only half of it; the value also has to reach SQL with a type.** A bound parameter arrives as `unknown` and takes its type from context. Against a typed column (`sort_order > $1`) that inference always succeeds, which is why the gap stays invisible almost everywhere — but as an argument to an overloaded function it can resolve to nothing at all. So: **if a bound value is an argument to a SQL function rather than one side of a comparison, write its type down** (`lib/api/list-query.ts`, `timestampKey`, casts from the column).
And this class survives a green test suite — `keysetAfter` returned well-formed SQL and every assertion passed; only Postgres's parser rejected it. When a change alters the *shape* of generated SQL rather than its values, execute it somewhere before believing the suite.
**Which of 403 and 404 an operation documents follows from its authorization, not from whether it is a read.** `requirePermission` throws two different failures: no workspace access at all is `NoWorkspaceAccessError`, which `createV2ResourceConcealmentPolicy` conceals as 404; access below the operation's `minimumRole` is `InsufficientWorkspacePermissionsError`, which stays a 403. So:
- 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.
## Rule 3 — a collection that returns `nextCursor` must accept `limit` + `cursor`, and must apply them
@@ -85,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:
@@ -110,6 +136,62 @@ Order matters because each layer is checked against the one before it.
3. **Route** with `defineV2JsonRoute`, declaring `contract`, `auth: v2ApiKeyAuth`, `operation`, `rateLimit`, `errorPolicy`, `mapInput`, `useCase`, `present`. Auth and rate limiting run before parsing.
4. **OpenAPI description** in `lib/api/contracts/v2/openapi/<domain>.ts`, then `bun run generate:openapi`. A description that claims behaviour the route does not have is the same class of bug as a wrong schema.
## Rule 6 — a transient failure says when to come back
A response the caller is *expected* to retry must say how long to wait. Two statuses qualify, and both are wired:
| Status | Source of the value | Where |
|---|---|---|
| 429 | The caller's own token bucket (`retryAfterMs`, else `resetAt - now`) | `v2RateLimitError` |
| 503 | A fixed floor, `RETRY_AFTER_SECONDS_BY_STATUS` | `v2Error`, applied automatically |
The 503 default is applied by `v2Error` keyed on the response *status* — `Retry-After` is defined against the status, and the status is the only half of the code/status pair a client sees — so every 503 the surface can emit carries it — the three route builders' `unhandledErrorResponse`, the execute and resume routes, and `serviceFailureResponse`'s `infra` failures. A route with a better number passes `headers: { 'Retry-After': … }` and wins.
Do not add a default for any other code. 400/403/404/409 are not fixed by waiting, and 402 (`USAGE_LIMIT_EXCEEDED`) is resolved by a billing change, not by time.
**Where a policy already knows the wait, carry it — do not re-guess it at the transport.** The admission descriptors in `lib/core/admission/transient-failure` declare `retryAfterSeconds` per denial. That value used to be dropped when the descriptor was mapped onto a preprocess error, so a concurrency denial arrived as a bare 429 with no `Retry-After` even though the policy had named the wait. It now travels `descriptor.retryAfterSeconds → PreprocessExecutionError.retryAfterMs → ExecuteWorkflowServiceFailure.retryAfterMs → serviceFailureResponse`. The `v2Error` default is the floor for paths with *no* policy signal, not the source of truth.
**A failure whose outcome is unknown must not advise a retry.** `ASYNC_ENQUEUE_AMBIGUOUS` is a 503 whose enqueue may have succeeded — it deliberately retains its execution-ID claim. Telling that caller to come back in 5 seconds invites a client with no `X-Run-Id` to start and bill a second run. It passes `omitRetryAfter: true` and returns the run id so the caller reconciles instead. Any future "we don't know if it happened" failure does the same.
RFC 9110 §10.2.3 gives 503 this field's clearest meaning — "how long the service is expected to be unavailable to the client". Note the requirement level is only `MAY`, on 503 (§15.6.4) and, via RFC 6585 §4, on 429. It is `SHOULD` on exactly one status, 413, and only when the condition is temporary; Sim's 413 is a fixed byte ceiling, so it correctly sends none.
## Deliberate non-adoptions
Audited against the primary specs and against Stripe, GitHub, and Google's AIPs. Each is a considered "no", not an oversight. Re-open one only with new evidence.
| Practice | Verdict | Why |
|---|---|---|
| **RFC 9457 `application/problem+json`** | No | 9457 §4 steers APIs with an existing format toward keeping it: "Problem details are intended to avoid the necessity of establishing new 'fault' or 'error' document formats, **not to replace existing domain-specific formats**." Nothing in it is a `MUST` to adopt, and none of Stripe, GitHub, or Google use it. Our envelope is load-bearing for every client. **The default error shape does not change.** |
| **`RateLimit`/`RateLimit-Policy` (IETF draft)** | No | Still an unpublished draft (`-11`, May 2026), returned "Not ready" at HTTPDIR review, and on its **third mutually incompatible wire format** — anything built against `-07` or earlier is already broken. None of the three surveyed APIs emit it; GitHub uses `x-ratelimit-*`, as we do. |
| **Renaming `X-RateLimit-*` per RFC 6648** | No | 6648 is a `SHOULD NOT` binding *creators of new* parameters, and §1 item 4 "**makes no recommendation as to whether existing 'X-' parameters ought to remain in use or be migrated**". Appendix B argues the migration is itself the interoperability harm. A rename is a client-visible break bought with nothing. |
| **`X-RateLimit-Reset` as delta-seconds** | No | It is an absolute ISO 8601 timestamp, so it is clock-skew sensitive — but the response where timing actually decides behaviour (429) also carries `Retry-After`, which is skew-free. The absolute value stays useful for scheduling. |
| **422 for semantic validation** | No | RFC 9110 §15.5.21 defines 422, but Appendix B.3 records that 9110 **deleted** RFC 4918's clause saying 400 was inappropriate. 400 covers "cannot or will not process… perceived to be a client error". The split is convention, not requirement — GitHub splits, Stripe and Google do not. Our machine-readable `error.code` already carries the distinction, and restatusing now breaks clients. |
| **`Location` on 201** | No | §9.3.3 makes this a `SHOULD` **for POST**; the status code itself (§15.3.2) requires nothing and defines the fallback — absent `Location`, the target URI identifies the resource. Declined knowingly: several 201 responses (signed upload sessions, table exports, knowledge folders) have no canonical single-resource GET, so a `Location` would 404, and adopting it on some of the 19 is worse for a client than on none. Every 201 returns the full representation including its `id`. Revisit per-route if one gains a canonical GET. |
| **ETag / `If-None-Match` / `If-Match`** | No | Every v2 response is `Cache-Control: private, no-store` per-caller data, so `If-None-Match` buys nothing. For writes, `If-Match` needs a **strong** validator: §8.8.3.2's strong comparison fails if *either* tag is weak, so a weak ETag silently makes every `If-Match` fail. None of the three surveyed APIs does HTTP optimistic concurrency — Google does the semantics via a resource `etag` **field** (AIP-154), deliberately not the header. If Sim needs optimistic concurrency, do it that way. |
| **`Deprecation` / `Sunset` on v1** | Not yet | RFC 9745 (Standards Track) and RFC 8594 (Informational) both apply, and GitHub emits both. But `Sunset` is a timestamp and 9745 §4 makes `Sunset >= Deprecation` a `MUST`, so emitting either commits Sim to a v1 retirement date — a product decision, not an engineering one. When that date exists: `Deprecation` is an RFC 9651 Structured Field **Date** (`@1688169599`); `Sunset` is an **HTTP-date** (`Sat, 31 Dec 2033 23:59:59 GMT`). Two encodings in one response — the most common implementation error here. |
| **`application/merge-patch+json`** | No | v2 PATCH bodies are merge-patch *shaped* — absent means unchanged, `null` clears — but they are `.strict()`, so unknown members are rejected where RFC 7396 §2 would merge them, and nested objects are replaced wholesale rather than merged. Advertising the media type would over-claim. Document the semantics per contract instead. |
## Idempotency: at-most-once, not replay
`POST /workflows/{id}/execute` accepts `X-Run-Id`, a caller-supplied run identifier claimed through the `idempotency_key` table (`execution-id-claim.ts`). It is a **uniqueness claim, not an idempotency key**, and the distinction is deliberate and already published in the operation description:
- First use wins and runs.
- Any reuse returns **409** with `error.details.code: "RUN_ID_CONFLICT"`, the run id in `error.details.runId`, and an `X-Run-Id` response header. It never replays the earlier run's result — the client recovers it by polling the runs resource.
- Claims are durable tombstones, so deleting execution logs cannot make an id reusable.
That makes the money path safe against double-execution **for callers that opt in**. What it is not: a Stripe-style `Idempotency-Key` that stores and replays the original status and body. Building that means a request fingerprint, a retention window, an in-flight-vs-completed distinction (the expired IETF draft would have these be 422 and 409 respectively), and somewhere to put a large synchronous execution body. It is a designed piece of work, not an increment — do not half-build it by aliasing the header name, which would invite clients written against Stripe semantics to treat our 409 as a hard failure.
## Cursors are opaque, not trusted
The base64-JSON cursor is **not signed**, and does not need to be. Tampering is bounded by construction, and that is a property to preserve:
- Every key value is re-validated by its `KeysetKey.bind`, which returns `null` for a wrong-typed or unparseable value and becomes a 400. A forged cursor cannot reach SQL as `NaN` or an `Invalid Date`.
- The sort is stamped into the cursor and re-checked (`decodeSortedCursor`), so a cursor from a differently-sorted query is a 400, not a silently skipped page.
- The offset codec rejects anything that is not a non-negative integer.
- Authorization is **never** carried in the cursor. Every list re-derives its workspace scope from the authenticated principal, so a cursor lifted from another query — or another tenant — can only move the caller within their own authorized result set.
The consequence to keep true: **never put a resource id, filter, or scope into a cursor and then trust it on the way back.** A cursor is a position hint, never an input to an access decision.
## Checklist
Run this against any new or changed v2 endpoint.
@@ -117,13 +199,16 @@ Run this against any new or changed v2 endpoint.
- [ ] Success body is exactly `{data}` or `{data, nextCursor}`; failures are exactly `{error:{code,message,details?}}`.
- [ ] Route uses a shared builder; no hand-built `NextResponse.json`.
- [ ] Query and body schemas are `.strict()`.
- [ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer.
- [ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer, and that any bound value passed as an argument to a SQL function carries an explicit type.
- [ ] `limit` comes from `v2PaginationFields`, not a hand-written `z.coerce.number()`.
- [ ] If the response carries `nextCursor`, the query accepts `limit` + `cursor` and the query actually applies them.
- [ ] Keyset sorts end in a unique `id` key.
- [ ] The list is classified in `list-pagination.test.ts`.
- [ ] Cross-tenant access answers 404, never 403.
- [ ] 403s carry a machine-readable `details.code`.
- [ ] 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` 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.
@@ -131,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.
+93 -8
View File
@@ -15,12 +15,13 @@ failure (always) { "error": { "code": "...", "message": "...", "details"?:
Nothing else at the top level. No `success: true`, no bare `{ "error": "string" }`, no HTML.
That promise is worth stating as a rule because it has been broken four separate ways, each time by a route or a builder taking a shortcut that looked local:
That promise is worth stating as a rule because it has been broken five separate ways, each time by a route or a builder taking a shortcut that looked local:
- `GET /workflows?limit=1.5` returned **500**. The contract was copied from a sibling and lost its `.int()`, so a fractional limit passed validation and reached Postgres as `LIMIT 2.5`.
- A malformed JSON body returned **`{"error":"Request body must be valid JSON"}`** — a bare string. The envelope was a per-route opt-in that only 8 of 77 routes remembered.
- `GET /api/v2/nonexistent` returned a **full HTML 404 document**, because no route file matched and the request fell through to the app's global not-found page.
- Four collections returned `nextCursor` while **silently discarding** any `limit` the caller sent, because Zod strips unknown keys unless the schema is `.strict()`.
- Handing back a `nextCursor` from any timestamp-sorted list and passing it straight in returned **500**. The value was validated and bound — but bound with no SQL type, into `date_trunc`, which is overloaded, so Postgres could resolve no overload. Validation was never the missing half; the type was.
Each was one line. The rules below are the generalisations.
@@ -50,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`. |
@@ -61,7 +62,26 @@ Two of these carry real design weight:
**404 is deliberately overloaded.** A workspace the caller cannot reach answers `404 "Workspace not found"`, never 403 — a 403 would confirm the resource exists. `createV2ResourceConcealmentPolicy` does this by mapping a cross-tenant authorization failure to `v2Error('NOT_FOUND', ...)`. The rollout gate answers the same way for the same reason (`gate.ts`: "an ungated caller cannot distinguish 'not in the rollout cohort' from 'no such endpoint'"), and so does the unknown-path catch-all at `app/api/v2/[[...segments]]/route.ts` — its body is byte-identical to the gate's on purpose.
**500 is never caller-reachable.** Any input a caller can send must be rejected at the contract boundary with a 400. If you can construct a query string or body that produces a 500, that is a bug in the contract, not something to wrap in a `try`/`catch`. `v2ErrorForOrchestration` also replaces the message on an unclassified failure with a generic one, so internal detail never leaks.
**500 is never caller-reachable.** Any input a caller can send must be rejected at the contract boundary with a 400. If you can construct a query string or body that produces a 500, that is a bug in the contract, not something to wrap in a `try`/`catch`. `v2ErrorForOrchestration` also replaces the message on an unclassified failure with a generic one, so internal detail never leaks. A caller-reachable 500 has shipped three times — a fractional `limit` reaching `LIMIT 2.5`, a plain `HEAD` tripping the builder's method guard, and a keyset cursor's timestamp reaching `date_trunc` as an untyped placeholder — so treat "a well-formed request produced a 500" as the highest-severity class of defect on this surface.
**Validating a value is only half of it; the value also has to reach SQL with a type.** A bound parameter arrives as `unknown` and takes its type from context. Against a typed column (`sort_order > $1`) that inference always succeeds, which is why the gap stays invisible almost everywhere — but as an argument to an overloaded function it can resolve to nothing at all. So: **if a bound value is an argument to a SQL function rather than one side of a comparison, write its type down** (`lib/api/list-query.ts`, `timestampKey`, casts from the column).
And this class survives a green test suite — `keysetAfter` returned well-formed SQL and every assertion passed; only Postgres's parser rejected it. When a change alters the *shape* of generated SQL rather than its values, execute it somewhere before believing the suite.
**Which of 403 and 404 an operation documents follows from its authorization, not from whether it is a read.** `requirePermission` throws two different failures: no workspace access at all is `NoWorkspaceAccessError`, which `createV2ResourceConcealmentPolicy` conceals as 404; access below the operation's `minimumRole` is `InsufficientWorkspacePermissionsError`, which stays a 403. So:
- 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.
## Rule 3 — a collection that returns `nextCursor` must accept `limit` + `cursor`, and must apply them
@@ -84,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:
@@ -109,6 +135,62 @@ Order matters because each layer is checked against the one before it.
3. **Route** with `defineV2JsonRoute`, declaring `contract`, `auth: v2ApiKeyAuth`, `operation`, `rateLimit`, `errorPolicy`, `mapInput`, `useCase`, `present`. Auth and rate limiting run before parsing.
4. **OpenAPI description** in `lib/api/contracts/v2/openapi/<domain>.ts`, then `bun run generate:openapi`. A description that claims behaviour the route does not have is the same class of bug as a wrong schema.
## Rule 6 — a transient failure says when to come back
A response the caller is *expected* to retry must say how long to wait. Two statuses qualify, and both are wired:
| Status | Source of the value | Where |
|---|---|---|
| 429 | The caller's own token bucket (`retryAfterMs`, else `resetAt - now`) | `v2RateLimitError` |
| 503 | A fixed floor, `RETRY_AFTER_SECONDS_BY_STATUS` | `v2Error`, applied automatically |
The 503 default is applied by `v2Error` keyed on the response *status* — `Retry-After` is defined against the status, and the status is the only half of the code/status pair a client sees — so every 503 the surface can emit carries it — the three route builders' `unhandledErrorResponse`, the execute and resume routes, and `serviceFailureResponse`'s `infra` failures. A route with a better number passes `headers: { 'Retry-After': … }` and wins.
Do not add a default for any other code. 400/403/404/409 are not fixed by waiting, and 402 (`USAGE_LIMIT_EXCEEDED`) is resolved by a billing change, not by time.
**Where a policy already knows the wait, carry it — do not re-guess it at the transport.** The admission descriptors in `lib/core/admission/transient-failure` declare `retryAfterSeconds` per denial. That value used to be dropped when the descriptor was mapped onto a preprocess error, so a concurrency denial arrived as a bare 429 with no `Retry-After` even though the policy had named the wait. It now travels `descriptor.retryAfterSeconds → PreprocessExecutionError.retryAfterMs → ExecuteWorkflowServiceFailure.retryAfterMs → serviceFailureResponse`. The `v2Error` default is the floor for paths with *no* policy signal, not the source of truth.
**A failure whose outcome is unknown must not advise a retry.** `ASYNC_ENQUEUE_AMBIGUOUS` is a 503 whose enqueue may have succeeded — it deliberately retains its execution-ID claim. Telling that caller to come back in 5 seconds invites a client with no `X-Run-Id` to start and bill a second run. It passes `omitRetryAfter: true` and returns the run id so the caller reconciles instead. Any future "we don't know if it happened" failure does the same.
RFC 9110 §10.2.3 gives 503 this field's clearest meaning — "how long the service is expected to be unavailable to the client". Note the requirement level is only `MAY`, on 503 (§15.6.4) and, via RFC 6585 §4, on 429. It is `SHOULD` on exactly one status, 413, and only when the condition is temporary; Sim's 413 is a fixed byte ceiling, so it correctly sends none.
## Deliberate non-adoptions
Audited against the primary specs and against Stripe, GitHub, and Google's AIPs. Each is a considered "no", not an oversight. Re-open one only with new evidence.
| Practice | Verdict | Why |
|---|---|---|
| **RFC 9457 `application/problem+json`** | No | 9457 §4 steers APIs with an existing format toward keeping it: "Problem details are intended to avoid the necessity of establishing new 'fault' or 'error' document formats, **not to replace existing domain-specific formats**." Nothing in it is a `MUST` to adopt, and none of Stripe, GitHub, or Google use it. Our envelope is load-bearing for every client. **The default error shape does not change.** |
| **`RateLimit`/`RateLimit-Policy` (IETF draft)** | No | Still an unpublished draft (`-11`, May 2026), returned "Not ready" at HTTPDIR review, and on its **third mutually incompatible wire format** — anything built against `-07` or earlier is already broken. None of the three surveyed APIs emit it; GitHub uses `x-ratelimit-*`, as we do. |
| **Renaming `X-RateLimit-*` per RFC 6648** | No | 6648 is a `SHOULD NOT` binding *creators of new* parameters, and §1 item 4 "**makes no recommendation as to whether existing 'X-' parameters ought to remain in use or be migrated**". Appendix B argues the migration is itself the interoperability harm. A rename is a client-visible break bought with nothing. |
| **`X-RateLimit-Reset` as delta-seconds** | No | It is an absolute ISO 8601 timestamp, so it is clock-skew sensitive — but the response where timing actually decides behaviour (429) also carries `Retry-After`, which is skew-free. The absolute value stays useful for scheduling. |
| **422 for semantic validation** | No | RFC 9110 §15.5.21 defines 422, but Appendix B.3 records that 9110 **deleted** RFC 4918's clause saying 400 was inappropriate. 400 covers "cannot or will not process… perceived to be a client error". The split is convention, not requirement — GitHub splits, Stripe and Google do not. Our machine-readable `error.code` already carries the distinction, and restatusing now breaks clients. |
| **`Location` on 201** | No | §9.3.3 makes this a `SHOULD` **for POST**; the status code itself (§15.3.2) requires nothing and defines the fallback — absent `Location`, the target URI identifies the resource. Declined knowingly: several 201 responses (signed upload sessions, table exports, knowledge folders) have no canonical single-resource GET, so a `Location` would 404, and adopting it on some of the 19 is worse for a client than on none. Every 201 returns the full representation including its `id`. Revisit per-route if one gains a canonical GET. |
| **ETag / `If-None-Match` / `If-Match`** | No | Every v2 response is `Cache-Control: private, no-store` per-caller data, so `If-None-Match` buys nothing. For writes, `If-Match` needs a **strong** validator: §8.8.3.2's strong comparison fails if *either* tag is weak, so a weak ETag silently makes every `If-Match` fail. None of the three surveyed APIs does HTTP optimistic concurrency — Google does the semantics via a resource `etag` **field** (AIP-154), deliberately not the header. If Sim needs optimistic concurrency, do it that way. |
| **`Deprecation` / `Sunset` on v1** | Not yet | RFC 9745 (Standards Track) and RFC 8594 (Informational) both apply, and GitHub emits both. But `Sunset` is a timestamp and 9745 §4 makes `Sunset >= Deprecation` a `MUST`, so emitting either commits Sim to a v1 retirement date — a product decision, not an engineering one. When that date exists: `Deprecation` is an RFC 9651 Structured Field **Date** (`@1688169599`); `Sunset` is an **HTTP-date** (`Sat, 31 Dec 2033 23:59:59 GMT`). Two encodings in one response — the most common implementation error here. |
| **`application/merge-patch+json`** | No | v2 PATCH bodies are merge-patch *shaped* — absent means unchanged, `null` clears — but they are `.strict()`, so unknown members are rejected where RFC 7396 §2 would merge them, and nested objects are replaced wholesale rather than merged. Advertising the media type would over-claim. Document the semantics per contract instead. |
## Idempotency: at-most-once, not replay
`POST /workflows/{id}/execute` accepts `X-Run-Id`, a caller-supplied run identifier claimed through the `idempotency_key` table (`execution-id-claim.ts`). It is a **uniqueness claim, not an idempotency key**, and the distinction is deliberate and already published in the operation description:
- First use wins and runs.
- Any reuse returns **409** with `error.details.code: "RUN_ID_CONFLICT"`, the run id in `error.details.runId`, and an `X-Run-Id` response header. It never replays the earlier run's result — the client recovers it by polling the runs resource.
- Claims are durable tombstones, so deleting execution logs cannot make an id reusable.
That makes the money path safe against double-execution **for callers that opt in**. What it is not: a Stripe-style `Idempotency-Key` that stores and replays the original status and body. Building that means a request fingerprint, a retention window, an in-flight-vs-completed distinction (the expired IETF draft would have these be 422 and 409 respectively), and somewhere to put a large synchronous execution body. It is a designed piece of work, not an increment — do not half-build it by aliasing the header name, which would invite clients written against Stripe semantics to treat our 409 as a hard failure.
## Cursors are opaque, not trusted
The base64-JSON cursor is **not signed**, and does not need to be. Tampering is bounded by construction, and that is a property to preserve:
- Every key value is re-validated by its `KeysetKey.bind`, which returns `null` for a wrong-typed or unparseable value and becomes a 400. A forged cursor cannot reach SQL as `NaN` or an `Invalid Date`.
- The sort is stamped into the cursor and re-checked (`decodeSortedCursor`), so a cursor from a differently-sorted query is a 400, not a silently skipped page.
- The offset codec rejects anything that is not a non-negative integer.
- Authorization is **never** carried in the cursor. Every list re-derives its workspace scope from the authenticated principal, so a cursor lifted from another query — or another tenant — can only move the caller within their own authorized result set.
The consequence to keep true: **never put a resource id, filter, or scope into a cursor and then trust it on the way back.** A cursor is a position hint, never an input to an access decision.
## Checklist
Run this against any new or changed v2 endpoint.
@@ -116,13 +198,16 @@ Run this against any new or changed v2 endpoint.
- [ ] Success body is exactly `{data}` or `{data, nextCursor}`; failures are exactly `{error:{code,message,details?}}`.
- [ ] Route uses a shared builder; no hand-built `NextResponse.json`.
- [ ] Query and body schemas are `.strict()`.
- [ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer.
- [ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer, and that any bound value passed as an argument to a SQL function carries an explicit type.
- [ ] `limit` comes from `v2PaginationFields`, not a hand-written `z.coerce.number()`.
- [ ] If the response carries `nextCursor`, the query accepts `limit` + `cursor` and the query actually applies them.
- [ ] Keyset sorts end in a unique `id` key.
- [ ] The list is classified in `list-pagination.test.ts`.
- [ ] Cross-tenant access answers 404, never 403.
- [ ] 403s carry a machine-readable `details.code`.
- [ ] 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` 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.
@@ -130,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.
+93 -8
View File
@@ -10,12 +10,13 @@ failure (always) { "error": { "code": "...", "message": "...", "details"?:
Nothing else at the top level. No `success: true`, no bare `{ "error": "string" }`, no HTML.
That promise is worth stating as a rule because it has been broken four separate ways, each time by a route or a builder taking a shortcut that looked local:
That promise is worth stating as a rule because it has been broken five separate ways, each time by a route or a builder taking a shortcut that looked local:
- `GET /workflows?limit=1.5` returned **500**. The contract was copied from a sibling and lost its `.int()`, so a fractional limit passed validation and reached Postgres as `LIMIT 2.5`.
- A malformed JSON body returned **`{"error":"Request body must be valid JSON"}`** — a bare string. The envelope was a per-route opt-in that only 8 of 77 routes remembered.
- `GET /api/v2/nonexistent` returned a **full HTML 404 document**, because no route file matched and the request fell through to the app's global not-found page.
- Four collections returned `nextCursor` while **silently discarding** any `limit` the caller sent, because Zod strips unknown keys unless the schema is `.strict()`.
- Handing back a `nextCursor` from any timestamp-sorted list and passing it straight in returned **500**. The value was validated and bound — but bound with no SQL type, into `date_trunc`, which is overloaded, so Postgres could resolve no overload. Validation was never the missing half; the type was.
Each was one line. The rules below are the generalisations.
@@ -45,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`. |
@@ -56,7 +57,26 @@ Two of these carry real design weight:
**404 is deliberately overloaded.** A workspace the caller cannot reach answers `404 "Workspace not found"`, never 403 — a 403 would confirm the resource exists. `createV2ResourceConcealmentPolicy` does this by mapping a cross-tenant authorization failure to `v2Error('NOT_FOUND', ...)`. The rollout gate answers the same way for the same reason (`gate.ts`: "an ungated caller cannot distinguish 'not in the rollout cohort' from 'no such endpoint'"), and so does the unknown-path catch-all at `app/api/v2/[[...segments]]/route.ts` — its body is byte-identical to the gate's on purpose.
**500 is never caller-reachable.** Any input a caller can send must be rejected at the contract boundary with a 400. If you can construct a query string or body that produces a 500, that is a bug in the contract, not something to wrap in a `try`/`catch`. `v2ErrorForOrchestration` also replaces the message on an unclassified failure with a generic one, so internal detail never leaks.
**500 is never caller-reachable.** Any input a caller can send must be rejected at the contract boundary with a 400. If you can construct a query string or body that produces a 500, that is a bug in the contract, not something to wrap in a `try`/`catch`. `v2ErrorForOrchestration` also replaces the message on an unclassified failure with a generic one, so internal detail never leaks. A caller-reachable 500 has shipped three times — a fractional `limit` reaching `LIMIT 2.5`, a plain `HEAD` tripping the builder's method guard, and a keyset cursor's timestamp reaching `date_trunc` as an untyped placeholder — so treat "a well-formed request produced a 500" as the highest-severity class of defect on this surface.
**Validating a value is only half of it; the value also has to reach SQL with a type.** A bound parameter arrives as `unknown` and takes its type from context. Against a typed column (`sort_order > $1`) that inference always succeeds, which is why the gap stays invisible almost everywhere — but as an argument to an overloaded function it can resolve to nothing at all. So: **if a bound value is an argument to a SQL function rather than one side of a comparison, write its type down** (`lib/api/list-query.ts`, `timestampKey`, casts from the column).
And this class survives a green test suite — `keysetAfter` returned well-formed SQL and every assertion passed; only Postgres's parser rejected it. When a change alters the *shape* of generated SQL rather than its values, execute it somewhere before believing the suite.
**Which of 403 and 404 an operation documents follows from its authorization, not from whether it is a read.** `requirePermission` throws two different failures: no workspace access at all is `NoWorkspaceAccessError`, which `createV2ResourceConcealmentPolicy` conceals as 404; access below the operation's `minimumRole` is `InsufficientWorkspacePermissionsError`, which stays a 403. So:
- 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.
## Rule 3 — a collection that returns `nextCursor` must accept `limit` + `cursor`, and must apply them
@@ -79,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:
@@ -104,6 +130,62 @@ Order matters because each layer is checked against the one before it.
3. **Route** with `defineV2JsonRoute`, declaring `contract`, `auth: v2ApiKeyAuth`, `operation`, `rateLimit`, `errorPolicy`, `mapInput`, `useCase`, `present`. Auth and rate limiting run before parsing.
4. **OpenAPI description** in `lib/api/contracts/v2/openapi/<domain>.ts`, then `bun run generate:openapi`. A description that claims behaviour the route does not have is the same class of bug as a wrong schema.
## Rule 6 — a transient failure says when to come back
A response the caller is *expected* to retry must say how long to wait. Two statuses qualify, and both are wired:
| Status | Source of the value | Where |
|---|---|---|
| 429 | The caller's own token bucket (`retryAfterMs`, else `resetAt - now`) | `v2RateLimitError` |
| 503 | A fixed floor, `RETRY_AFTER_SECONDS_BY_STATUS` | `v2Error`, applied automatically |
The 503 default is applied by `v2Error` keyed on the response *status* — `Retry-After` is defined against the status, and the status is the only half of the code/status pair a client sees — so every 503 the surface can emit carries it — the three route builders' `unhandledErrorResponse`, the execute and resume routes, and `serviceFailureResponse`'s `infra` failures. A route with a better number passes `headers: { 'Retry-After': … }` and wins.
Do not add a default for any other code. 400/403/404/409 are not fixed by waiting, and 402 (`USAGE_LIMIT_EXCEEDED`) is resolved by a billing change, not by time.
**Where a policy already knows the wait, carry it — do not re-guess it at the transport.** The admission descriptors in `lib/core/admission/transient-failure` declare `retryAfterSeconds` per denial. That value used to be dropped when the descriptor was mapped onto a preprocess error, so a concurrency denial arrived as a bare 429 with no `Retry-After` even though the policy had named the wait. It now travels `descriptor.retryAfterSeconds → PreprocessExecutionError.retryAfterMs → ExecuteWorkflowServiceFailure.retryAfterMs → serviceFailureResponse`. The `v2Error` default is the floor for paths with *no* policy signal, not the source of truth.
**A failure whose outcome is unknown must not advise a retry.** `ASYNC_ENQUEUE_AMBIGUOUS` is a 503 whose enqueue may have succeeded — it deliberately retains its execution-ID claim. Telling that caller to come back in 5 seconds invites a client with no `X-Run-Id` to start and bill a second run. It passes `omitRetryAfter: true` and returns the run id so the caller reconciles instead. Any future "we don't know if it happened" failure does the same.
RFC 9110 §10.2.3 gives 503 this field's clearest meaning — "how long the service is expected to be unavailable to the client". Note the requirement level is only `MAY`, on 503 (§15.6.4) and, via RFC 6585 §4, on 429. It is `SHOULD` on exactly one status, 413, and only when the condition is temporary; Sim's 413 is a fixed byte ceiling, so it correctly sends none.
## Deliberate non-adoptions
Audited against the primary specs and against Stripe, GitHub, and Google's AIPs. Each is a considered "no", not an oversight. Re-open one only with new evidence.
| Practice | Verdict | Why |
|---|---|---|
| **RFC 9457 `application/problem+json`** | No | 9457 §4 steers APIs with an existing format toward keeping it: "Problem details are intended to avoid the necessity of establishing new 'fault' or 'error' document formats, **not to replace existing domain-specific formats**." Nothing in it is a `MUST` to adopt, and none of Stripe, GitHub, or Google use it. Our envelope is load-bearing for every client. **The default error shape does not change.** |
| **`RateLimit`/`RateLimit-Policy` (IETF draft)** | No | Still an unpublished draft (`-11`, May 2026), returned "Not ready" at HTTPDIR review, and on its **third mutually incompatible wire format** — anything built against `-07` or earlier is already broken. None of the three surveyed APIs emit it; GitHub uses `x-ratelimit-*`, as we do. |
| **Renaming `X-RateLimit-*` per RFC 6648** | No | 6648 is a `SHOULD NOT` binding *creators of new* parameters, and §1 item 4 "**makes no recommendation as to whether existing 'X-' parameters ought to remain in use or be migrated**". Appendix B argues the migration is itself the interoperability harm. A rename is a client-visible break bought with nothing. |
| **`X-RateLimit-Reset` as delta-seconds** | No | It is an absolute ISO 8601 timestamp, so it is clock-skew sensitive — but the response where timing actually decides behaviour (429) also carries `Retry-After`, which is skew-free. The absolute value stays useful for scheduling. |
| **422 for semantic validation** | No | RFC 9110 §15.5.21 defines 422, but Appendix B.3 records that 9110 **deleted** RFC 4918's clause saying 400 was inappropriate. 400 covers "cannot or will not process… perceived to be a client error". The split is convention, not requirement — GitHub splits, Stripe and Google do not. Our machine-readable `error.code` already carries the distinction, and restatusing now breaks clients. |
| **`Location` on 201** | No | §9.3.3 makes this a `SHOULD` **for POST**; the status code itself (§15.3.2) requires nothing and defines the fallback — absent `Location`, the target URI identifies the resource. Declined knowingly: several 201 responses (signed upload sessions, table exports, knowledge folders) have no canonical single-resource GET, so a `Location` would 404, and adopting it on some of the 19 is worse for a client than on none. Every 201 returns the full representation including its `id`. Revisit per-route if one gains a canonical GET. |
| **ETag / `If-None-Match` / `If-Match`** | No | Every v2 response is `Cache-Control: private, no-store` per-caller data, so `If-None-Match` buys nothing. For writes, `If-Match` needs a **strong** validator: §8.8.3.2's strong comparison fails if *either* tag is weak, so a weak ETag silently makes every `If-Match` fail. None of the three surveyed APIs does HTTP optimistic concurrency — Google does the semantics via a resource `etag` **field** (AIP-154), deliberately not the header. If Sim needs optimistic concurrency, do it that way. |
| **`Deprecation` / `Sunset` on v1** | Not yet | RFC 9745 (Standards Track) and RFC 8594 (Informational) both apply, and GitHub emits both. But `Sunset` is a timestamp and 9745 §4 makes `Sunset >= Deprecation` a `MUST`, so emitting either commits Sim to a v1 retirement date — a product decision, not an engineering one. When that date exists: `Deprecation` is an RFC 9651 Structured Field **Date** (`@1688169599`); `Sunset` is an **HTTP-date** (`Sat, 31 Dec 2033 23:59:59 GMT`). Two encodings in one response — the most common implementation error here. |
| **`application/merge-patch+json`** | No | v2 PATCH bodies are merge-patch *shaped* — absent means unchanged, `null` clears — but they are `.strict()`, so unknown members are rejected where RFC 7396 §2 would merge them, and nested objects are replaced wholesale rather than merged. Advertising the media type would over-claim. Document the semantics per contract instead. |
## Idempotency: at-most-once, not replay
`POST /workflows/{id}/execute` accepts `X-Run-Id`, a caller-supplied run identifier claimed through the `idempotency_key` table (`execution-id-claim.ts`). It is a **uniqueness claim, not an idempotency key**, and the distinction is deliberate and already published in the operation description:
- First use wins and runs.
- Any reuse returns **409** with `error.details.code: "RUN_ID_CONFLICT"`, the run id in `error.details.runId`, and an `X-Run-Id` response header. It never replays the earlier run's result — the client recovers it by polling the runs resource.
- Claims are durable tombstones, so deleting execution logs cannot make an id reusable.
That makes the money path safe against double-execution **for callers that opt in**. What it is not: a Stripe-style `Idempotency-Key` that stores and replays the original status and body. Building that means a request fingerprint, a retention window, an in-flight-vs-completed distinction (the expired IETF draft would have these be 422 and 409 respectively), and somewhere to put a large synchronous execution body. It is a designed piece of work, not an increment — do not half-build it by aliasing the header name, which would invite clients written against Stripe semantics to treat our 409 as a hard failure.
## Cursors are opaque, not trusted
The base64-JSON cursor is **not signed**, and does not need to be. Tampering is bounded by construction, and that is a property to preserve:
- Every key value is re-validated by its `KeysetKey.bind`, which returns `null` for a wrong-typed or unparseable value and becomes a 400. A forged cursor cannot reach SQL as `NaN` or an `Invalid Date`.
- The sort is stamped into the cursor and re-checked (`decodeSortedCursor`), so a cursor from a differently-sorted query is a 400, not a silently skipped page.
- The offset codec rejects anything that is not a non-negative integer.
- Authorization is **never** carried in the cursor. Every list re-derives its workspace scope from the authenticated principal, so a cursor lifted from another query — or another tenant — can only move the caller within their own authorized result set.
The consequence to keep true: **never put a resource id, filter, or scope into a cursor and then trust it on the way back.** A cursor is a position hint, never an input to an access decision.
## Checklist
Run this against any new or changed v2 endpoint.
@@ -111,13 +193,16 @@ Run this against any new or changed v2 endpoint.
- [ ] Success body is exactly `{data}` or `{data, nextCursor}`; failures are exactly `{error:{code,message,details?}}`.
- [ ] Route uses a shared builder; no hand-built `NextResponse.json`.
- [ ] Query and body schemas are `.strict()`.
- [ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer.
- [ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer, and that any bound value passed as an argument to a SQL function carries an explicit type.
- [ ] `limit` comes from `v2PaginationFields`, not a hand-written `z.coerce.number()`.
- [ ] If the response carries `nextCursor`, the query accepts `limit` + `cursor` and the query actually applies them.
- [ ] Keyset sorts end in a unique `id` key.
- [ ] The list is classified in `list-pagination.test.ts`.
- [ ] Cross-tenant access answers 404, never 403.
- [ ] 403s carry a machine-readable `details.code`.
- [ ] 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` 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.
@@ -125,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 -1
View File
@@ -697,7 +697,15 @@ jobs:
desktop-release:
name: Desktop Release
needs: [create-release, check-desktop-signing, detect-version]
if: needs.check-desktop-signing.outputs.configured == 'true'
# Suppress the implicit success() check: check-desktop-signing has an
# intentionally skipped transitive dependency on main, which would
# otherwise cascade-skip this job even when every direct need succeeded.
if: >-
!cancelled() &&
needs.create-release.result == 'success' &&
needs.check-desktop-signing.result == 'success' &&
needs.detect-version.result == 'success' &&
needs.check-desktop-signing.outputs.configured == 'true'
permissions:
contents: write
uses: ./.github/workflows/desktop-release.yml
+2 -1
View File
@@ -16,7 +16,7 @@ import { CodeBlock } from '@/components/ui/code-block'
import { Heading } from '@/components/ui/heading'
import { ResponseSection } from '@/components/ui/response-section'
import { i18n } from '@/lib/i18n'
import { getApiSpecContent, openapi } from '@/lib/openapi'
import { getApiSpecContent, getAuthenticatedCodeSamples, openapi } from '@/lib/openapi'
import { type PageData, source } from '@/lib/source'
import { DOCS_BASE_URL } from '@/lib/urls'
@@ -71,6 +71,7 @@ function stripLocalePrefix(url: string, lang: string): string {
const APIPage = createAPIPage(openapi, {
playground: { enabled: false },
generateCodeSamples: getAuthenticatedCodeSamples,
client: {
operation: { APIExampleSelector },
},
+26
View File
@@ -2494,6 +2494,32 @@ export function DocumentIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function WindchillIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox='79 57 88 88'
role='img'
aria-label='Windchill icon'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<rect x='79' y='57' width='88' height='88' fill='#FFFFFF' />
<g fill='#3D4647'>
<polygon points='137.2,86.6 137.2,68.5 122.8,60.2 107.1,69.2' />
<polygon points='142.7,106.4 158.4,97.3 158.4,80.7 142.7,71.6' />
<polygon points='108.5,115.8 108.5,133.9 122.8,142.2 138.5,133.2' />
<polygon points='128.2,121 143.9,130.1 158.4,121.7 158.4,103.6' />
<polygon points='117.4,81.4 101.7,72.4 87.3,80.7 87.3,98.8' />
<polygon points='103,96 87.3,105.1 87.3,121.7 103,130.8' />
</g>
<polygon
fill='#40AA1D'
points='137.2,109.5 122.8,117.8 108.4,109.5 108.4,92.9 122.8,84.6 137.2,92.9'
/>
</svg>
)
}
export function MintlifyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 19 19' fill='none'>
+2
View File
@@ -245,6 +245,7 @@ import {
WebhookIcon,
WhatsAppIcon,
WikipediaIcon,
WindchillIcon,
WizaIcon,
WordpressIcon,
WorkdayIcon,
@@ -538,6 +539,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
webflow: WebflowIcon,
whatsapp: WhatsAppIcon,
wikipedia: WikipediaIcon,
windchill: WindchillIcon,
wiza: WizaIcon,
wordpress: WordpressIcon,
workday: WorkdayIcon,
@@ -9,6 +9,7 @@
"getWorkflowVersionV2",
"exportWorkflow",
"importWorkflow",
"getWorkflowDeployment",
"deployWorkflow",
"undeployWorkflow",
"rollbackWorkflow",
@@ -259,6 +259,7 @@
"webflow-service-account",
"whatsapp",
"wikipedia",
"windchill",
"wiza",
"wordpress",
"workday",
@@ -0,0 +1,937 @@
---
title: Windchill
description: Manage documents, revisions, and content in PTC Windchill
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="windchill"
color="#FFFFFF"
/>
{/* MANUAL-CONTENT-START:intro */}
[PTC Windchill](https://www.ptc.com/en/products/windchill) is the product lifecycle management system manufacturers use as the system of record for engineering data. Documents in Windchill are controlled objects: each one carries a number, a revision and iteration, a lifecycle state, folder placement, security labels, and a checkout status that decides who is allowed to change it right now.
This integration talks to Windchill REST Services (WRS) 2.7 over OData — the query protocol Windchill exposes its data through — using a Basic-authenticated service account. Point it at a complete versioned service root — `https://your-host/Windchill/servlet/odata/v6` — and your agents can:
- **Find and read documents**: list documents with an OData filter, sort order, field selection, page size, and a latest-version-only switch; fetch a single document by its object identifier (OID); and walk a document's structure through its usage links to see child documents with their versions and states.
- **Create and update**: create one document or a batch of them in a container and optional folder, and patch editable attributes on one or many documents. Name, Number, and Organization are rejected here and have their own operation, because Windchill changes those through a separate action and refuses it while a document is checked out.
- **Run the version and lifecycle cycle**: check documents out and back in with notes, undo a checkout, revise to the next revision, read the lifecycle states a document is actually allowed to move to, and transition it to one of them.
- **Move files**: download a document's primary content, or a specific attachment by its OID, into a Sim file — and upload files as primary content or attachments. Sim handles Windchill's CSRF token and its multi-step upload handshake for you.
Bulk actions are atomic on Windchill's side: PTC documents that if the action fails for any object in the collection, the entire action is rolled back and nothing changes.
Two limits are worth knowing before you build. Windchill identifies everything by OID (`OR:wt.doc.WTDocument:48796581`), so most operations need an OID you got from a list or get call rather than a document number. And this integration supports Basic authentication only — Windchill deployments fronted by OAuth are not currently supported.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate PTC Windchill REST Services 2.7 document management into your workflow using Basic authentication. Read and update document metadata, perform version and lifecycle actions, and transfer primary content and attachments. Windchill OAuth deployments are not currently supported.
## Actions
### Windchill List Documents
List documents with an OData query, sorting, and pagination
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `select` | string | No | Comma-separated normalized document properties to return |
| `filter` | string | No | OData $filter expression |
| `orderBy` | string | No | OData $orderby expression |
| `top` | number | No | Maximum documents in the OData result set \($top\), from 1 to 2000 |
| `skip` | number | No | Documents to skip |
| `count` | boolean | No | Ask Windchill to include the total matching count |
| `latestVersion` | boolean | No | Return only the latest version of matching documents |
| `nextLink` | string | No | Verified @odata.nextLink from a previous list response |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `documents` | array | Windchill documents |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
| `pageInfo` | object | OData pagination information |
| ↳ `count` | number | Number of items returned in this page |
| ↳ `totalCount` | number | Total matching items |
| ↳ `nextLink` | string | URL returned by Windchill for the next page |
### Windchill Get Document
Get a WT.Document by OID
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
| `select` | string | No | Comma-separated normalized document properties to return |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `document` | object | Windchill document |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Get Document Structure
Retrieve recursive document usage links and their parent and child documents
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
| `structureDepth` | number | No | Document structure expansion depth, from 1 to 3 |
| `nextLink` | string | No | Verified @odata.nextLink from a previous structure response |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `structure` | array | Document usage links, including recursively expanded child links |
| ↳ `id` | string | Document usage link OID |
| ↳ `parent` | object | Parent document |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
| ↳ `child` | object | Child document |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
| ↳ `children` | array | Nested child usage links with the same recursive shape |
| `pageInfo` | object | OData pagination information |
| ↳ `count` | number | Number of items returned in this page |
| ↳ `totalCount` | number | Total matching items |
| ↳ `nextLink` | string | URL returned by Windchill for the next page |
### Windchill Get Valid State Transitions
Get lifecycle states a document can transition to from its current state
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `states` | array | Valid lifecycle transitions |
| ↳ `value` | string | Internal state value |
| ↳ `display` | string | Displayed state value |
### Windchill Get Primary Content
Get primary-content metadata for a document
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `content` | object | Primary-content metadata |
| ↳ `id` | string | Content object identifier |
| ↳ `fileName` | string | Content file name |
| ↳ `description` | string | Content description |
| ↳ `format` | string | Windchill content format |
| ↳ `mimeType` | string | Content MIME type |
| ↳ `fileSize` | number | Content size in bytes |
| ↳ `contentType` | string | Windchill OData content entity type |
| ↳ `displayName` | string | Displayed content name |
| ↳ `urlLocation` | string | URL-data location |
| ↳ `externalLocation` | string | External-storage location |
### Windchill List Attachments
List attachment metadata for a document
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
| `nextLink` | string | No | Verified @odata.nextLink from a previous attachment response |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `attachments` | array | Document attachments |
| ↳ `id` | string | Content object identifier |
| ↳ `fileName` | string | Content file name |
| ↳ `description` | string | Content description |
| ↳ `format` | string | Windchill content format |
| ↳ `mimeType` | string | Content MIME type |
| ↳ `fileSize` | number | Content size in bytes |
| ↳ `contentType` | string | Windchill OData content entity type |
| ↳ `displayName` | string | Displayed content name |
| ↳ `urlLocation` | string | URL-data location |
| ↳ `externalLocation` | string | External-storage location |
| `pageInfo` | object | OData pagination information |
| ↳ `count` | number | Number of items returned in this page |
| ↳ `totalCount` | number | Total matching items |
| ↳ `nextLink` | string | URL returned by Windchill for the next page |
### Windchill Create Document
Create one WT.Document
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `name` | string | Yes | Document name |
| `containerOid` | string | Yes | Container OID in which to create the document |
| `number` | string | No | Optional document number when manual numbering is enabled |
| `title` | string | No | Document title |
| `description` | string | No | Document description |
| `folderOid` | string | No | Optional folder OID for the new document |
| `attributes` | json | No | Optional installed Windchill document attributes as a JSON object |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `document` | object | Document returned by Windchill when the operation returns one |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Create Documents
Create several documents in one atomic Windchill request
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documents` | array | Yes | Document inputs as a JSON array; each item requires name and containerOid |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `documents` | array | Documents returned by Windchill when the operation returns them |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Update Document
Update one document's editable attributes
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
| `attributes` | json | Yes | Editable attributes as a JSON object. Name, Number, and Organization require the Update Common Properties operation and are not supported here. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `document` | object | Document returned by Windchill when the operation returns one |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Update Common Properties
Update a document's Name, Number, and other common properties
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581. The document must not be checked out. |
| `commonProperties` | json | Yes | Common properties as a JSON object, for example \{"Name":"New name","Number":"NEW-001"\}. Enumerated properties take a value/display pair. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `document` | object | Document returned by Windchill when the operation returns one |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Update Documents
Update several documents' editable attributes in one atomic request
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documents` | array | Yes | Document updates as a JSON array; each item requires id and the editable attributes to set. Name, Number, and Organization are not supported. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `documents` | array | Documents returned by Windchill when the operation returns them |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Delete Document
Delete one document
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
### Windchill Delete Documents
Delete multiple documents atomically
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOids` | array | Yes | WT.Document OIDs to process atomically |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
### Windchill Check Out Document
Check out one document
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
| `checkOutNote` | string | No | Checkout note |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `document` | object | Document returned by Windchill when the operation returns one |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Check Out Documents
Check out multiple documents atomically
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOids` | array | Yes | WT.Document OIDs to process atomically |
| `checkOutNote` | string | No | Checkout note |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `documents` | array | Documents returned by Windchill when the operation returns them |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Check In Document
Check in one document
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
| `checkInNote` | string | No | Check-in note |
| `keepCheckedOut` | boolean | No | Keep the document checked out after checking it in |
| `checkOutNote` | string | No | Checkout note |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `document` | object | Document returned by Windchill when the operation returns one |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Check In Documents
Check in multiple documents atomically
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOids` | array | Yes | WT.Document OIDs to process atomically |
| `checkInNote` | string | No | Check-in note |
| `keepCheckedOut` | boolean | No | Keep the document checked out after checking it in |
| `checkOutNote` | string | No | Checkout note |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `documents` | array | Documents returned by Windchill when the operation returns them |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Undo Check Out Document
Undo checkout for one document
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `document` | object | Document returned by Windchill when the operation returns one |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Undo Check Out Documents
Undo checkout for multiple documents atomically
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOids` | array | Yes | WT.Document OIDs to process atomically |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `documents` | array | Documents returned by Windchill when the operation returns them |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Revise Document
Create a new revision of one document
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
| `versionId` | string | No | Optional target revision identifier when override-on-revise is enabled |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `document` | object | Document returned by Windchill when the operation returns one |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Revise Documents
Create new revisions of multiple documents atomically
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOids` | array | Yes | WT.Document OIDs to process atomically |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `documents` | array | Documents returned by Windchill when the operation returns them |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Set Lifecycle State
Transition a document to a valid lifecycle state
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
| `stateValue` | string | Yes | Internal value of the target lifecycle state |
| `stateDisplay` | string | Yes | Display value of the target lifecycle state |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `document` | object | Document returned by Windchill when the operation returns one |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Update Document Security Labels
Update installed security-label attributes for one or more documents
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `securityLabelUpdates` | array | Yes | Array of document IDs and installed security-label values |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the operation |
| `documents` | array | Documents returned by Windchill when the operation returns them |
| ↳ `id` | string | Windchill object identifier |
| ↳ `name` | string | Document name |
| ↳ `number` | string | Document number |
| ↳ `title` | string | Document title |
| ↳ `description` | string | Document description |
| ↳ `state` | string | Internal life cycle state value |
| ↳ `stateDisplay` | string | Displayed life cycle state value |
| ↳ `versionId` | string | Version identifier |
| ↳ `revision` | string | Revision identifier |
| ↳ `version` | string | Version and iteration |
| ↳ `latest` | boolean | Whether this is the latest version |
| ↳ `checkoutState` | string | Checkout state |
| ↳ `folderName` | string | Folder name |
| ↳ `folderLocation` | string | Folder path |
### Windchill Download Primary Content
Download primary content into a canonical UserFile
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
| `fileName` | string | No | Optional downloaded file name override |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `file` | file | Downloaded content stored as a canonical UserFile |
| `fileName` | string | Downloaded file name |
| `mimeType` | string | Downloaded content MIME type |
### Windchill Upload Primary Content
Upload a primary-content file to a document that has none
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
| `primaryFile` | file | Yes | Primary content file to upload |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the upload |
| `uploadedFileNames` | array | Names of files accepted by Windchill |
### Windchill Download Attachment
Download a document attachment into a canonical UserFile
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
| `attachmentOid` | string | Yes | Windchill attachment content OID |
| `fileName` | string | No | Optional downloaded file name override |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `file` | file | Downloaded content stored as a canonical UserFile |
| `fileName` | string | Downloaded file name |
| `mimeType` | string | Downloaded content MIME type |
### Windchill Upload Attachments
Upload one or more files as document attachments
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
| `username` | string | Yes | Windchill service-account username |
| `password` | string | Yes | Windchill service-account password |
| `documentOid` | string | Yes | WT.Document OID, for example OR:wt.doc.WTDocument:48796581 |
| `attachmentFiles` | file[] | Yes | Attachment files to upload |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `operation` | string | Windchill operation that was executed |
| `affectedIds` | array | Document identifiers affected by the upload |
| `uploadedFileNames` | array | Names of files accepted by Windchill |
@@ -115,6 +115,7 @@ Live tool-call chips stream for **OpenAI, Anthropic, Azure Anthropic, Google, Ve
| Google | Summaries only | `gemini-3.6-flash`, `gemini-3.5-flash-lite`, `gemini-3.5-flash`, `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite`, `gemini-3-flash-preview`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` |
| Vertex AI | Summaries only | `vertex/gemini-3.5-flash`, `vertex/gemini-3.1-pro-preview`, `vertex/gemini-3.1-flash-lite`, `vertex/gemini-3-flash-preview`, `vertex/gemini-2.5-pro`, `vertex/gemini-2.5-flash`, `vertex/gemini-2.5-flash-lite` |
| DeepSeek | Full thinking deltas | `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-reasoner` |
| xAI | Full thinking deltas | `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309` |
| Groq | Full thinking deltas | `groq/openai/gpt-oss-120b`, `groq/openai/gpt-oss-20b`, `groq/openai/gpt-oss-safeguard-20b`, `groq/qwen/qwen3.6-27b` |
| Meta | Not streamed | `muse-spark-1.1` |
| Kimi | Full thinking deltas | `kimi-k2.6` |
@@ -142,7 +143,7 @@ The Agent reads the message from Start with `<start.input>` and returns a result
{ question: "What are the memory options for the Agent block?", answer: "Four modes: None (no memory, each run is independent), Conversation (full history keyed by a conversation ID), Sliding window by messages (the N most recent messages), and Sliding window by tokens (messages up to a token budget). Memory needs a conversation ID to persist across runs." },
{ question: "What is the difference between the tool usage controls (Auto, Force, None)?", answer: "In Auto, the model decides when to call a tool based on context. In Force, the model must call the tool on every run. In None, the tool is hidden from the model and never sent, which disables it without removing it from the block." },
{ question: "How does the Response Format work?", answer: "It enforces structured output by providing a JSON Schema. When set, the model's response is constrained to match the schema exactly, and each field is read directly by downstream blocks using <agent.fieldName>. Without a response format, the agent returns its standard outputs: content, model, tokens, and toolCalls." },
{ question: "What does the Reasoning Effort / Thinking Level setting do?", answer: "They appear only for models that support extended reasoning. Reasoning Effort (OpenAI o-series and GPT-5 models) and Thinking Level (Anthropic Claude and Gemini models with thinking) control how much compute the model spends reasoning before responding. Higher levels produce more thorough answers but cost more tokens and take longer." },
{ question: "What does the Reasoning Effort / Thinking Level setting do?", answer: "They appear only for models that support extended reasoning. Reasoning Effort (OpenAI, Azure OpenAI, xAI Grok, DeepSeek, Groq, Meta, and Z.ai models that accept an effort level) and Thinking Level (Anthropic Claude and Gemini models with thinking) control how much compute the model spends reasoning before responding. Higher levels produce more thorough answers but cost more tokens and take longer." },
{ question: "When should I turn on Prompt Caching?", answer: "Turn it on when the same agent runs repeatedly with a large, stable system prompt or tool set — cached input bills at a tenth of the normal input rate. Leave it off for one-off runs, because writing the cache costs 1.25x and nothing reads it back. The setting appears only for Anthropic Claude models; OpenAI and Gemini cache automatically with no setting and no write fee. Anthropic only caches a prefix of at least 1,024 tokens (2,048 on Haiku), and entries expire after five minutes of no use." },
{ question: "How does max output tokens work with Anthropic models?", answer: "The Agent block uses each Anthropic model's full max output token limit by default (for example, 64,000 tokens). You can override this with the Max Output Tokens setting. For non-streaming requests that exceed the SDK's internal threshold, the provider automatically uses internal streaming to avoid timeouts." },
{ question: "Can I use the Agent block with a custom or self-hosted model?", answer: "Yes. Use any Ollama or VLLM-compatible model by typing the model name directly into the model combobox, as long as it exposes a compatible API endpoint." },
@@ -0,0 +1,37 @@
'use client'
import type { CodeUsageGeneratorFn } from 'fumadocs-openapi/requests/generators'
import { createCodeUsageGeneratorRegistry } from 'fumadocs-openapi/requests/generators'
import { registerDefault } from 'fumadocs-openapi/requests/generators/all'
/**
* Context handed to {@link generateWithAuth} by the server: which built-in
* generator to delegate to, and the auth headers the sample must send.
*/
export interface AuthCodeSampleContext {
generatorId: string
headers: Record<string, string>
}
const generators = createCodeUsageGeneratorRegistry()
registerDefault(generators)
/**
* Wraps a built-in code-usage generator so the sample carries the operation's
* security headers. Fumadocs builds request data from declared parameters only,
* so an operation's security requirement never reaches the generated snippet.
*/
export const generateWithAuth: CodeUsageGeneratorFn = (url, data, context) => {
const { generatorId, headers } = context.server as AuthCodeSampleContext
const generator = generators.get(generatorId)
if (!generator) {
throw new Error(`[docs] Unknown code usage generator: ${generatorId}`)
}
const authHeaders: Record<string, { value: string }> = {}
for (const [name, value] of Object.entries(headers)) {
authHeaders[name] = { value }
}
return generator.generate(url, { ...data, header: { ...authHeaders, ...data.header } }, context)
}
+21
View File
@@ -0,0 +1,21 @@
import type { InlineCodeUsageGenerator } from 'fumadocs-openapi/requests/generators'
import { createCodeUsageGeneratorRegistry } from 'fumadocs-openapi/requests/generators'
import { registerDefault } from 'fumadocs-openapi/requests/generators/all'
import { generateWithAuth } from '@/lib/openapi-code-samples-client'
const generators = createCodeUsageGeneratorRegistry()
registerDefault(generators)
/**
* Replace every built-in language sample with one that prepends `headers`,
* preserving the built-in tab order, language, and label.
*/
export function buildAuthCodeSamples(headers: Record<string, string>): InlineCodeUsageGenerator[] {
return Array.from(generators.map().entries()).map(([id, generator]) => ({
id,
lang: generator.lang,
label: generator.label,
source: generateWithAuth,
serverContext: { generatorId: id, headers },
}))
}
+106
View File
@@ -1,6 +1,9 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import type { MethodInformation } from 'fumadocs-openapi'
import type { InlineCodeUsageGenerator } from 'fumadocs-openapi/requests/generators'
import { createOpenAPI } from 'fumadocs-openapi/server'
import { buildAuthCodeSamples } from '@/lib/openapi-code-samples'
import { OPENAPI_SPEC_FILES } from '@/lib/openapi-specs'
export const openapi = createOpenAPI({
@@ -75,6 +78,109 @@ function getSpecs(): Record<string, unknown>[] {
return cachedSpecs
}
type SecurityRequirement = Record<string, string[]>
interface SecurityScheme {
type?: string
in?: string
name?: string
scheme?: string
}
interface SharedSecurity {
security: SecurityRequirement[]
schemes: Record<string, SecurityScheme>
}
const AUTH_SAMPLE_VALUE = 'YOUR_API_KEY'
let cachedSharedSecurity: SharedSecurity | null = null
/**
* Document-level security shared by every rendered spec. Code samples are
* generated from an operation alone, with no handle on the document that owns
* it, so the specs must agree on their default security — a spec that diverges
* would silently get another document's auth in its samples.
*/
function getSharedSecurity(): SharedSecurity {
if (cachedSharedSecurity) return cachedSharedSecurity
let shared: SharedSecurity | undefined
let sharedFile: string | undefined
getSpecs().forEach((spec, index) => {
const file = OPENAPI_SPEC_FILES[index]
const current: SharedSecurity = {
security: (spec.security as SecurityRequirement[] | undefined) ?? [],
schemes:
((spec.components as Record<string, unknown> | undefined)?.securitySchemes as
| Record<string, SecurityScheme>
| undefined) ?? {},
}
if (!shared) {
shared = current
sharedFile = file
return
}
if (JSON.stringify(current) !== JSON.stringify(shared)) {
throw new Error(
`[docs] ${file} declares different default security than ${sharedFile}. Every OpenAPI spec must share one security scheme so generated code samples stay correct.`
)
}
})
cachedSharedSecurity = shared ?? { security: [], schemes: {} }
return cachedSharedSecurity
}
/**
* Resolve a security requirement to the request headers a sample must send.
* The first non-empty alternative wins — an empty one means the operation also
* accepts anonymous callers, which is not what a reference example should show.
*/
function resolveAuthHeaders(
security: SecurityRequirement[],
schemes: Record<string, SecurityScheme>
): Record<string, string> {
const requirement = security.find((item) => Object.keys(item).length > 0)
if (!requirement) return {}
const headers: Record<string, string> = {}
for (const name of Object.keys(requirement)) {
const scheme = schemes[name]
if (!scheme) {
throw new Error(`[docs] Operation references undefined security scheme "${name}"`)
}
if (scheme.type === 'apiKey' && scheme.in === 'header' && scheme.name) {
headers[scheme.name] = AUTH_SAMPLE_VALUE
continue
}
if (scheme.type === 'http' && scheme.scheme === 'bearer') {
headers.Authorization = `Bearer ${AUTH_SAMPLE_VALUE}`
continue
}
throw new Error(
`[docs] Security scheme "${name}" (type ${scheme.type}) cannot be rendered as a request header in code samples`
)
}
return headers
}
/**
* Code samples for an operation, with its authentication header included.
* Fumadocs derives sample requests from declared parameters only, so without
* this every endpoint documents an unauthenticated call that returns `401`.
*/
export function getAuthenticatedCodeSamples(method: MethodInformation): InlineCodeUsageGenerator[] {
const shared = getSharedSecurity()
const security = (method.security as SecurityRequirement[] | undefined) ?? shared.security
const headers = resolveAuthHeaders(security, shared.schemes)
if (Object.keys(headers).length === 0) return []
return buildAuthCodeSamples(headers)
}
/**
* Locate an operation by path + method across every rendered spec, returning the
* operation together with the spec that owns it so `$ref`s resolve within the
+9 -4
View File
@@ -283,13 +283,13 @@
}
},
"Retry-After": {
"description": "Seconds to wait before retrying a rate-limited request.",
"description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.",
"schema": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991,
"title": "Retry after",
"description": "Seconds to wait before retrying a rate-limited request."
"description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset."
}
},
"X-Run-Id": {
@@ -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": {
@@ -454,7 +454,12 @@
}
},
"ServiceUnavailable": {
"description": "A required service is temporarily unavailable.",
"description": "A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.",
"headers": {
"Retry-After": {
"$ref": "#/components/headers/Retry-After"
}
},
"content": {
"application/json": {
"schema": {
+197 -12
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",
@@ -370,6 +382,9 @@
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
@@ -468,6 +483,9 @@
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
@@ -555,6 +573,9 @@
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
@@ -669,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": [
{
@@ -825,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",
@@ -977,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"
}
},
{
@@ -1912,13 +2012,13 @@
}
},
"Retry-After": {
"description": "Seconds to wait before retrying a rate-limited request.",
"description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.",
"schema": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991,
"title": "Retry after",
"description": "Seconds to wait before retrying a rate-limited request."
"description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset."
}
},
"X-Run-Id": {
@@ -1963,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": {
@@ -2083,7 +2183,12 @@
}
},
"ServiceUnavailable": {
"description": "A required service is temporarily unavailable.",
"description": "A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.",
"headers": {
"Retry-After": {
"$ref": "#/components/headers/Retry-After"
}
},
"content": {
"application/json": {
"schema": {
@@ -2181,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": [
@@ -2192,7 +2310,8 @@
"folderPath",
"uploadedByEmail",
"uploadedAt",
"updatedAt"
"updatedAt",
"deletedAt"
],
"additionalProperties": false,
"title": "Workspace file",
@@ -2236,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
@@ -2266,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
}
}
]
@@ -2682,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": {
@@ -2798,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": [
{
@@ -2820,6 +3002,7 @@
"uploadedByEmail",
"uploadedAt",
"updatedAt",
"deletedAt",
"share"
],
"additionalProperties": false,
@@ -2850,6 +3033,7 @@
"uploadedByEmail": "jane@example.com",
"uploadedAt": "2026-01-15T10:30:00Z",
"updatedAt": "2026-01-15T10:30:00Z",
"deletedAt": null,
"share": null
}
},
@@ -2864,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",
+768 -34
View File
@@ -295,6 +295,9 @@
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
@@ -471,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,
@@ -536,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": [
{
@@ -642,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": {
@@ -672,6 +764,83 @@
"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"
}
}
},
"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"
},
@@ -1252,6 +1421,94 @@
"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"
}
}
},
"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"
},
@@ -1732,13 +1989,13 @@
}
},
"Retry-After": {
"description": "Seconds to wait before retrying a rate-limited request.",
"description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.",
"schema": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991,
"title": "Retry after",
"description": "Seconds to wait before retrying a rate-limited request."
"description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset."
}
},
"X-Run-Id": {
@@ -1783,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": {
@@ -1903,7 +2160,12 @@
}
},
"ServiceUnavailable": {
"description": "A required service is temporarily unavailable.",
"description": "A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.",
"headers": {
"Retry-After": {
"$ref": "#/components/headers/Retry-After"
}
},
"content": {
"application/json": {
"schema": {
@@ -2288,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.",
@@ -2348,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",
@@ -2509,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"
@@ -2527,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": {
@@ -2618,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": {
@@ -3131,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": [
{
@@ -3214,6 +3786,7 @@
"characterCount",
"enabled",
"createdAt",
"tags",
"processingError",
"processingStartedAt",
"processingCompletedAt",
@@ -3238,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": {
+21 -12
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."
}
},
{
@@ -394,13 +398,13 @@
}
},
"Retry-After": {
"description": "Seconds to wait before retrying a rate-limited request.",
"description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.",
"schema": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991,
"title": "Retry after",
"description": "Seconds to wait before retrying a rate-limited request."
"description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset."
}
},
"X-Run-Id": {
@@ -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": {
@@ -565,7 +569,12 @@
}
},
"ServiceUnavailable": {
"description": "A required service is temporarily unavailable.",
"description": "A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.",
"headers": {
"Retry-After": {
"$ref": "#/components/headers/Retry-After"
}
},
"content": {
"application/json": {
"schema": {
@@ -652,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",
@@ -1048,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",
+241 -11
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",
@@ -1938,13 +2051,13 @@
}
},
"Retry-After": {
"description": "Seconds to wait before retrying a rate-limited request.",
"description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.",
"schema": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991,
"title": "Retry after",
"description": "Seconds to wait before retrying a rate-limited request."
"description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset."
}
},
"X-Run-Id": {
@@ -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": {
@@ -2109,7 +2222,12 @@
}
},
"ServiceUnavailable": {
"description": "A required service is temporarily unavailable.",
"description": "A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.",
"headers": {
"Retry-After": {
"$ref": "#/components/headers/Retry-After"
}
},
"content": {
"application/json": {
"schema": {
@@ -2865,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": {
+191 -13
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",
@@ -3622,13 +3701,13 @@
}
},
"Retry-After": {
"description": "Seconds to wait before retrying a rate-limited request.",
"description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.",
"schema": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991,
"title": "Retry after",
"description": "Seconds to wait before retrying a rate-limited request."
"description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset."
}
},
"X-Run-Id": {
@@ -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": {
@@ -3793,7 +3872,12 @@
}
},
"ServiceUnavailable": {
"description": "A required service is temporarily unavailable.",
"description": "A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.",
"headers": {
"Retry-After": {
"$ref": "#/components/headers/Retry-After"
}
},
"content": {
"application/json": {
"schema": {
@@ -4167,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"
@@ -4445,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"
@@ -4536,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"
@@ -4607,6 +4703,7 @@
}
},
"required": ["workspaceId", "columnName"],
"additionalProperties": false,
"title": "Delete table column request",
"description": "Workspace scope and column name to delete.",
"examples": [
@@ -4768,7 +4865,8 @@
"description": "Rows to insert, with cells keyed by column name."
}
},
"required": ["workspaceId", "rows"]
"required": ["workspaceId", "rows"],
"additionalProperties": false
},
{
"type": "object",
@@ -4793,7 +4891,8 @@
"minLength": 1
}
},
"required": ["workspaceId", "data"]
"required": ["workspaceId", "data"],
"additionalProperties": false
}
],
"title": "Create table rows request",
@@ -4864,6 +4963,7 @@
}
},
"required": ["workspaceId", "filter", "data"],
"additionalProperties": false,
"title": "Update table rows request",
"description": "Workspace scope, typed predicate, and row-data patch."
},
@@ -4940,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": [
@@ -4976,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": [
@@ -5068,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": [
@@ -5138,7 +5241,8 @@
"description": "Sort direction for this column."
}
},
"required": ["field", "direction"]
"required": ["field", "direction"],
"additionalProperties": false
}
},
"limit": {
@@ -5154,6 +5258,7 @@
}
},
"required": ["workspaceId"],
"additionalProperties": false,
"title": "Query table rows request",
"description": "Workspace scope, optional predicate and sort, and cursor pagination controls.",
"examples": [
@@ -5178,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": {
@@ -5442,7 +5606,8 @@
"description": "Sort direction for this column."
}
},
"required": ["field", "direction"]
"required": ["field", "direction"],
"additionalProperties": false
}
},
{
@@ -5451,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."
},
@@ -5552,7 +5719,8 @@
"description": "Sort direction for this column."
}
},
"required": ["field", "direction"]
"required": ["field", "direction"],
"additionalProperties": false
}
},
{
@@ -5560,7 +5728,8 @@
}
]
}
}
},
"additionalProperties": false
},
"configPatch": {
"description": "Saved-view configuration fields to shallow-merge.",
@@ -5630,7 +5799,8 @@
"description": "Sort direction for this column."
}
},
"required": ["field", "direction"]
"required": ["field", "direction"],
"additionalProperties": false
}
},
{
@@ -5638,7 +5808,8 @@
}
]
}
}
},
"additionalProperties": false
},
"isDefault": {
"description": "Whether to promote this view to the table default.",
@@ -5646,6 +5817,7 @@
}
},
"required": ["workspaceId"],
"additionalProperties": false,
"title": "Update table view request",
"description": "Workspace scope and one or more saved-view changes."
},
@@ -6483,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": [
@@ -6515,6 +6688,7 @@
}
},
"required": ["workspaceId"],
"additionalProperties": false,
"title": "Run row enrichment request",
"description": "Workspace scope for the row enrichment.",
"examples": [
@@ -6612,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": [
@@ -7619,6 +7795,7 @@
}
},
"required": ["workspaceId"],
"additionalProperties": false,
"title": "Create table export request",
"description": "Workspace scope and export format.",
"examples": [
@@ -7749,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": [
+199 -12
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"
}
},
{
@@ -2018,13 +2083,13 @@
}
},
"Retry-After": {
"description": "Seconds to wait before retrying a rate-limited request.",
"description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.",
"schema": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991,
"title": "Retry after",
"description": "Seconds to wait before retrying a rate-limited request."
"description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset."
}
},
"X-Run-Id": {
@@ -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": {
@@ -2189,7 +2254,12 @@
}
},
"ServiceUnavailable": {
"description": "A required service is temporarily unavailable.",
"description": "A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.",
"headers": {
"Retry-After": {
"$ref": "#/components/headers/Retry-After"
}
},
"content": {
"application/json": {
"schema": {
@@ -3063,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,836 @@
/**
* @vitest-environment node
*/
import { createMockRequest as createTestingRequest, resetEnvMock } from '@sim/testing'
import { NextResponse } from 'next/server'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
const {
MockInvalidBindingError,
MockWindchillProviderError,
mockAssertToolFileAccess,
mockBindDelegation,
mockCreateWindchillSession,
mockDownloadServableFileFromStorage,
mockDownloadWindchillContent,
mockGetSession,
mockResolveWindchillContentUrl,
mockProcessFilesToUserFiles,
mockUploadCopilotFile,
mockUploadExecutionFile,
mockUploadWindchillContent,
mockWindchillMutationRequest,
} = vi.hoisted(() => {
class MockInvalidBindingError extends Error {}
class MockWindchillProviderError extends Error {
constructor(
message: string,
readonly status: number
) {
super(message)
this.name = 'WindchillProviderError'
}
}
return {
MockInvalidBindingError,
MockWindchillProviderError,
mockAssertToolFileAccess: vi.fn(),
mockBindDelegation: vi.fn(),
mockCreateWindchillSession: vi.fn(),
mockDownloadServableFileFromStorage: vi.fn(),
mockDownloadWindchillContent: vi.fn(),
mockGetSession: vi.fn(),
mockResolveWindchillContentUrl: vi.fn(),
mockProcessFilesToUserFiles: vi.fn(),
mockUploadCopilotFile: vi.fn(),
mockUploadExecutionFile: vi.fn(),
mockUploadWindchillContent: vi.fn(),
mockWindchillMutationRequest: vi.fn(),
}
})
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
vi.mock('@/lib/auth/internal-delegation', () => ({
bindInternalExecutorDelegation: mockBindDelegation,
InvalidInternalDelegationBindingError: MockInvalidBindingError,
}))
vi.unmock('@/lib/auth/internal')
vi.mock('@/app/api/files/authorization', () => ({
assertToolFileAccess: mockAssertToolFileAccess,
}))
vi.mock('@/lib/uploads/utils/file-utils', () => ({
processFilesToUserFiles: mockProcessFilesToUserFiles,
}))
vi.mock('@/lib/uploads/utils/file-utils.server', () => ({
downloadServableFileFromStorage: mockDownloadServableFileFromStorage,
}))
vi.mock('@/lib/uploads/utils/servable-file-response', () => ({
docNotReadyResponse: vi.fn().mockReturnValue(null),
}))
vi.mock('@/lib/uploads/contexts/copilot', () => ({
uploadCopilotFile: mockUploadCopilotFile,
}))
vi.mock('@/lib/uploads/contexts/execution', () => ({
uploadExecutionFile: mockUploadExecutionFile,
}))
vi.mock('@/tools/windchill/utils.server', () => ({
createWindchillSession: mockCreateWindchillSession,
downloadWindchillContent: mockDownloadWindchillContent,
resolveWindchillContentUrl: mockResolveWindchillContentUrl,
sanitizeWindchillError: (message: string) => message.replace(/https?:\/\/\S+/g, '[redacted URL]'),
uploadWindchillContent: mockUploadWindchillContent,
windchillDocumentUrl: (baseUrl: string, documentOid: string) =>
`${baseUrl}/DocMgmt/Documents('${encodeURIComponent(documentOid)}')`,
windchillMutationRequest: mockWindchillMutationRequest,
WindchillProviderError: MockWindchillProviderError,
}))
import { generateInternalDelegationToken, generateInternalToken } from '@/lib/auth/internal'
import { POST } from '@/app/api/tools/windchill/route'
const BASE_BODY = {
baseUrl: 'https://windchill.example.com/Windchill/servlet/odata/v6',
username: 'windchill-user',
password: 'not-a-real-password',
}
const DOCUMENT_OID = 'OR:wt.doc.WTDocument:1'
const SECOND_DOCUMENT_OID = 'OR:wt.doc.WTDocument:2'
let delegationToken = ''
let legacyInternalToken = ''
function createMockRequest(method: string, body: unknown, headers: Record<string, string> = {}) {
return createTestingRequest(method, body, {
authorization: `Bearer ${delegationToken}`,
...headers,
})
}
const MUTATION_CASES = [
{
operation: 'windchill_create_document',
input: { name: 'Specification', containerOid: 'OR:wt.pdmlink.PDMLinkProduct:1' },
url: '/DocMgmt/Documents',
method: 'POST',
},
{
operation: 'windchill_create_documents',
input: {
documents: [{ name: 'Specification', containerOid: 'OR:wt.pdmlink.PDMLinkProduct:1' }],
},
url: '/DocMgmt/CreateDocuments',
method: 'POST',
},
{
operation: 'windchill_update_document',
input: { documentOid: DOCUMENT_OID, attributes: { Title: 'Updated' } },
url: '/DocMgmt/Documents(',
method: 'PATCH',
},
{
operation: 'windchill_update_documents',
input: { documents: [{ id: DOCUMENT_OID, attributes: { Title: 'Updated' } }] },
url: '/DocMgmt/UpdateDocuments',
method: 'POST',
},
{
operation: 'windchill_update_common_properties',
input: { documentOid: DOCUMENT_OID, commonProperties: { Name: 'Renamed' } },
url: '/PTC.DocMgmt.UpdateCommonProperties',
method: 'POST',
},
{
operation: 'windchill_delete_document',
input: { documentOid: DOCUMENT_OID },
url: '/DocMgmt/Documents(',
method: 'DELETE',
},
{
operation: 'windchill_delete_documents',
input: { documentOids: [DOCUMENT_OID, SECOND_DOCUMENT_OID] },
url: '/DocMgmt/DeleteDocuments',
method: 'POST',
},
{
operation: 'windchill_check_out_document',
input: { documentOid: DOCUMENT_OID, checkOutNote: 'Editing' },
url: '/PTC.DocMgmt.CheckOut',
method: 'POST',
},
{
operation: 'windchill_check_out_documents',
input: { documentOids: [DOCUMENT_OID], checkOutNote: 'Editing' },
url: '/DocMgmt/CheckOutDocuments',
method: 'POST',
},
{
operation: 'windchill_check_in_document',
input: { documentOid: DOCUMENT_OID, checkInNote: 'Done', keepCheckedOut: false },
url: '/PTC.DocMgmt.CheckIn',
method: 'POST',
},
{
operation: 'windchill_check_in_documents',
input: { documentOids: [DOCUMENT_OID], checkInNote: 'Done' },
url: '/DocMgmt/CheckInDocuments',
method: 'POST',
},
{
operation: 'windchill_undo_check_out_document',
input: { documentOid: DOCUMENT_OID },
url: '/PTC.DocMgmt.UndoCheckOut',
method: 'POST',
},
{
operation: 'windchill_undo_check_out_documents',
input: { documentOids: [DOCUMENT_OID] },
url: '/DocMgmt/UndoCheckOutDocuments',
method: 'POST',
},
{
operation: 'windchill_revise_document',
input: { documentOid: DOCUMENT_OID, versionId: 'B' },
url: '/PTC.DocMgmt.Revise',
method: 'POST',
},
{
operation: 'windchill_revise_documents',
input: { documentOids: [DOCUMENT_OID] },
url: '/DocMgmt/ReviseDocuments',
method: 'POST',
},
{
operation: 'windchill_set_lifecycle_state',
input: { documentOid: DOCUMENT_OID, stateValue: 'RELEASED', stateDisplay: 'Released' },
url: '/PTC.DocMgmt.SetState',
method: 'POST',
},
{
operation: 'windchill_update_document_security_labels',
input: {
securityLabelUpdates: [{ id: DOCUMENT_OID, labels: { EXPORT_CONTROL: 'L1' } }],
},
url: '/DocMgmt/EditDocumentsSecurityLabels',
method: 'POST',
},
] as const
const MUTATION_PAYLOAD_CASES = [
{
operation: 'windchill_check_out_documents',
input: { documentOids: [DOCUMENT_OID], checkOutNote: 'Editing' },
body: { Documents: [{ ID: DOCUMENT_OID }], CheckOutNote: 'Editing' },
},
{
operation: 'windchill_check_in_document',
input: {
documentOid: DOCUMENT_OID,
checkInNote: 'Done',
keepCheckedOut: false,
checkOutNote: 'Continue editing',
},
body: {
CheckInNote: 'Done',
KeepCheckedOut: false,
CheckOutNote: 'Continue editing',
},
},
{
operation: 'windchill_revise_document',
input: { documentOid: DOCUMENT_OID, versionId: 'B' },
body: { VersionId: 'B' },
},
{
operation: 'windchill_update_common_properties',
input: {
documentOid: DOCUMENT_OID,
commonProperties: { Name: 'Renamed', Number: 'DOC-001' },
},
body: { Updates: { Name: 'Renamed', Number: 'DOC-001' } },
},
{
operation: 'windchill_revise_documents',
input: { documentOids: [DOCUMENT_OID] },
body: { Documents: [{ ID: DOCUMENT_OID }] },
},
{
operation: 'windchill_set_lifecycle_state',
input: { documentOid: DOCUMENT_OID, stateValue: 'RELEASED', stateDisplay: 'Released' },
body: { State: { Display: 'Released', Value: 'RELEASED' } },
},
{
operation: 'windchill_update_document_security_labels',
input: {
securityLabelUpdates: [{ id: DOCUMENT_OID, labels: { EXPORT_CONTROL: 'L1' } }],
},
body: { Documents: [{ EXPORT_CONTROL: 'L1', ID: DOCUMENT_OID }] },
},
] as const
beforeAll(async () => {
delegationToken = await generateInternalDelegationToken({
subjectUserId: 'user-1',
workflowId: '550e8400-e29b-41d4-a716-446655440001',
})
legacyInternalToken = await generateInternalToken()
})
afterAll(resetEnvMock)
beforeEach(() => {
vi.clearAllMocks()
mockGetSession.mockResolvedValue(null)
mockBindDelegation.mockImplementation(async (delegation, options) => ({
kind: 'delegated',
serviceId: 'executor',
subjectUserId: delegation.subjectUserId,
workspaceId: '550e8400-e29b-41d4-a716-446655440000',
delegationId: delegation.delegationId,
audience: options.audience,
issuedAt: delegation.issuedAt,
expiresAt: delegation.expiresAt,
delegationContext: {
kind: 'workflow_execution',
workflowId: delegation.workflowId,
executionId: delegation.executionId,
},
}))
mockCreateWindchillSession.mockResolvedValue({
nonceHeader: 'CSRF_NONCE',
nonceValue: 'nonce-value',
cookie: 'JSESSIONID=session-value',
})
mockWindchillMutationRequest.mockResolvedValue({ value: [{ ID: DOCUMENT_OID }] })
mockAssertToolFileAccess.mockResolvedValue(null)
mockProcessFilesToUserFiles.mockReturnValue([
{
key: 'workspace/workspace-1/specification.pdf',
name: 'specification.pdf',
size: 3,
type: 'application/pdf',
},
])
mockDownloadServableFileFromStorage.mockResolvedValue({
buffer: Buffer.from('pdf'),
contentType: 'application/pdf',
})
mockUploadWindchillContent.mockResolvedValue(['specification.pdf'])
mockResolveWindchillContentUrl.mockImplementation(
async ({ contentPath }: { contentPath: string }) =>
`https://windchill.example.com/Windchill/servlet/WindchillGW/download?from=${encodeURIComponent(contentPath)}`
)
mockDownloadWindchillContent.mockResolvedValue({
buffer: Buffer.from('pdf'),
contentType: 'application/pdf',
contentDisposition: 'attachment; filename="specification.pdf"',
})
mockUploadCopilotFile.mockResolvedValue({
id: 'file-1',
name: 'specification.pdf',
url: '/api/files/serve?key=copilot/specification.pdf',
size: 3,
type: 'application/pdf',
key: 'copilot/specification.pdf',
})
})
describe('POST /api/tools/windchill', () => {
it('authenticates before parsing the request body', async () => {
const response = await POST(createTestingRequest('POST', { operation: 'not-valid' }))
expect(response.status).toBe(401)
expect(await response.json()).toEqual({ success: false, error: 'Unauthorized' })
expect(mockCreateWindchillSession).not.toHaveBeenCalled()
})
it('binds executor identity and scope through the canonical delegation path', async () => {
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_update_document',
documentOid: DOCUMENT_OID,
attributes: { Title: 'Updated' },
})
)
expect(response.status).toBe(200)
expect(mockBindDelegation).toHaveBeenCalledWith(expect.any(Object), {
audience: 'sim:windchill',
resourceScope: undefined,
})
})
it('rejects browser sessions and legacy internal tokens', async () => {
mockGetSession.mockResolvedValueOnce({
user: { id: 'user-1' },
session: { id: 'session-1' },
})
const sessionResponse = await POST(createTestingRequest('POST', BASE_BODY))
const legacyResponse = await POST(
createTestingRequest('POST', BASE_BODY, {
authorization: `Bearer ${legacyInternalToken}`,
})
)
expect(sessionResponse.status).toBe(401)
expect(legacyResponse.status).toBe(401)
expect(mockBindDelegation).not.toHaveBeenCalled()
})
it('rejects malformed operation inputs at the shared contract boundary', async () => {
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_update_document',
documentOid: DOCUMENT_OID,
attributes: {},
})
)
expect(response.status).toBe(400)
expect(mockCreateWindchillSession).not.toHaveBeenCalled()
})
it('rejects an invalid service root before reading a protected upload', async () => {
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
baseUrl: `${BASE_BODY.baseUrl}?token=secret`,
operation: 'windchill_upload_primary_content',
documentOid: DOCUMENT_OID,
primaryFile: {
key: 'workspace/workspace-1/specification.pdf',
name: 'specification.pdf',
size: 3,
type: 'application/pdf',
},
})
)
expect(response.status).toBe(400)
expect(mockAssertToolFileAccess).not.toHaveBeenCalled()
expect(mockDownloadServableFileFromStorage).not.toHaveBeenCalled()
})
it.each(MUTATION_CASES)(
'dispatches $operation through one CSRF-protected transaction',
async ({ operation, input, url, method }) => {
const response = await POST(createMockRequest('POST', { ...BASE_BODY, operation, ...input }))
expect(response.status).toBe(200)
expect((await response.json()).success).toBe(true)
expect(mockCreateWindchillSession).toHaveBeenCalledTimes(1)
expect(mockWindchillMutationRequest).toHaveBeenCalledTimes(1)
expect(mockWindchillMutationRequest.mock.calls[0][0].url).toContain(url)
expect(mockWindchillMutationRequest.mock.calls[0][0].method).toBe(method)
}
)
it.each(MUTATION_PAYLOAD_CASES)(
'encodes the exact $operation action payload',
async ({ operation, input, body }) => {
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation,
...input,
})
)
expect(response.status).toBe(200)
expect(mockWindchillMutationRequest.mock.calls[0][0].body).toEqual(body)
}
)
it('maps create bindings and custom attributes without allowing them to replace bindings', async () => {
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_create_document',
name: 'Specification',
containerOid: 'OR:wt.pdmlink.PDMLinkProduct:1',
folderOid: 'OR:wt.folder.SubFolder:2',
attributes: { CustomString: 'value' },
})
)
expect(response.status).toBe(200)
expect((await response.json()).output.affectedIds).toEqual([DOCUMENT_OID])
expect(mockWindchillMutationRequest.mock.calls[0][0].body).toEqual({
CustomString: 'value',
Name: 'Specification',
'Context@odata.bind': "Containers('OR%3Awt.pdmlink.PDMLinkProduct%3A1')",
'Folder@odata.bind': "Folders('OR%3Awt.folder.SubFolder%3A2')",
})
})
it('returns operation-specific single, bulk, and delete mutation shapes', async () => {
const singleResponse = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_update_document',
documentOid: DOCUMENT_OID,
attributes: { Title: 'Updated' },
})
)
const singleOutput = (await singleResponse.json()).output
expect(singleOutput.document).toMatchObject({ id: DOCUMENT_OID })
expect(singleOutput).not.toHaveProperty('documents')
const bulkResponse = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_update_documents',
documents: [{ id: DOCUMENT_OID, attributes: { Title: 'Updated' } }],
})
)
const bulkOutput = (await bulkResponse.json()).output
expect(bulkOutput.documents).toEqual([expect.objectContaining({ id: DOCUMENT_OID })])
expect(bulkOutput).not.toHaveProperty('document')
const deleteResponse = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_delete_document',
documentOid: DOCUMENT_OID,
})
)
const deleteOutput = (await deleteResponse.json()).output
expect(deleteOutput.affectedIds).toEqual([DOCUMENT_OID])
expect(deleteOutput).not.toHaveProperty('document')
expect(deleteOutput).not.toHaveProperty('documents')
})
it('authorizes and reads a UserFile before starting the upload transaction', async () => {
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_upload_primary_content',
documentOid: DOCUMENT_OID,
primaryFile: {
key: 'workspace/workspace-1/specification.pdf',
name: 'specification.pdf',
size: 3,
type: 'application/pdf',
},
})
)
expect(response.status).toBe(200)
expect(mockAssertToolFileAccess).toHaveBeenCalledWith(
'workspace/workspace-1/specification.pdf',
'user-1',
expect.any(String),
expect.anything()
)
expect(mockDownloadServableFileFromStorage).toHaveBeenCalledTimes(1)
expect(mockUploadWindchillContent).toHaveBeenCalledWith(
expect.objectContaining({
documentOid: DOCUMENT_OID,
primaryContent: true,
files: [
expect.objectContaining({
name: 'specification.pdf',
mimeType: 'application/pdf',
size: 3,
}),
],
})
)
})
it('uploads multiple authorized files as attachments', async () => {
mockProcessFilesToUserFiles.mockReturnValueOnce([
{
key: 'workspace/workspace-1/one.txt',
name: 'one.txt',
size: 3,
type: 'text/plain',
},
{
key: 'workspace/workspace-1/two.txt',
name: 'two.txt',
size: 3,
type: 'text/plain',
},
])
mockDownloadServableFileFromStorage.mockResolvedValue({
buffer: Buffer.from('txt'),
contentType: 'text/plain',
})
mockUploadWindchillContent.mockResolvedValueOnce(['one.txt', 'two.txt'])
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_upload_attachments',
documentOid: DOCUMENT_OID,
attachmentFiles: [
{ key: 'workspace/workspace-1/one.txt', name: 'one.txt', size: 3 },
{ key: 'workspace/workspace-1/two.txt', name: 'two.txt', size: 3 },
],
})
)
expect(response.status).toBe(200)
expect(mockAssertToolFileAccess).toHaveBeenCalledTimes(2)
expect(mockDownloadServableFileFromStorage).toHaveBeenCalledTimes(2)
expect(mockUploadWindchillContent).toHaveBeenCalledWith(
expect.objectContaining({ primaryContent: false })
)
expect(mockDownloadServableFileFromStorage.mock.calls[0][3]).toEqual({
maxBytes: MAX_FILE_SIZE,
})
expect(mockDownloadServableFileFromStorage.mock.calls[1][3]).toEqual({
maxBytes: MAX_FILE_SIZE - 3,
})
})
it('rejects attachment counts above the contract limit before reading storage', async () => {
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_upload_attachments',
documentOid: DOCUMENT_OID,
attachmentFiles: Array.from({ length: 11 }, (_, index) => ({
key: `workspace/workspace-1/${index}.txt`,
name: `${index}.txt`,
size: 1,
})),
})
)
expect(response.status).toBe(400)
expect(mockAssertToolFileAccess).not.toHaveBeenCalled()
expect(mockDownloadServableFileFromStorage).not.toHaveBeenCalled()
})
it('rejects declared aggregate upload size before reading storage', async () => {
mockProcessFilesToUserFiles.mockReturnValueOnce([
{
key: 'workspace/workspace-1/oversized.bin',
name: 'oversized.bin',
size: MAX_FILE_SIZE + 1,
type: 'application/octet-stream',
},
])
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_upload_primary_content',
documentOid: DOCUMENT_OID,
primaryFile: {
key: 'workspace/workspace-1/oversized.bin',
name: 'oversized.bin',
size: MAX_FILE_SIZE + 1,
},
})
)
expect(response.status).toBe(413)
expect(mockAssertToolFileAccess).not.toHaveBeenCalled()
expect(mockDownloadServableFileFromStorage).not.toHaveBeenCalled()
})
it('stops an under-reported upload at the remaining aggregate byte budget', async () => {
mockProcessFilesToUserFiles.mockReturnValueOnce([
{ key: 'workspace/workspace-1/one.txt', name: 'one.txt', size: 1, type: 'text/plain' },
{ key: 'workspace/workspace-1/two.txt', name: 'two.txt', size: 1, type: 'text/plain' },
{
key: 'workspace/workspace-1/three.txt',
name: 'three.txt',
size: 1,
type: 'text/plain',
},
])
mockDownloadServableFileFromStorage
.mockResolvedValueOnce({ buffer: Buffer.from('one'), contentType: 'text/plain' })
.mockRejectedValueOnce(
new PayloadSizeLimitError({
label: 'Uploaded file',
maxBytes: MAX_FILE_SIZE - 3,
observedBytes: MAX_FILE_SIZE - 2,
})
)
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_upload_attachments',
documentOid: DOCUMENT_OID,
attachmentFiles: [
{ key: 'workspace/workspace-1/one.txt', name: 'one.txt', size: 1 },
{ key: 'workspace/workspace-1/two.txt', name: 'two.txt', size: 1 },
{ key: 'workspace/workspace-1/three.txt', name: 'three.txt', size: 1 },
],
})
)
expect(response.status).toBe(413)
expect(mockDownloadServableFileFromStorage).toHaveBeenCalledTimes(2)
expect(mockDownloadServableFileFromStorage.mock.calls[1][3]).toEqual({
maxBytes: MAX_FILE_SIZE - 3,
})
expect(mockUploadWindchillContent).not.toHaveBeenCalled()
})
it('stops before storage or Windchill when file ownership is denied', async () => {
mockAssertToolFileAccess.mockResolvedValueOnce(
NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
)
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_upload_primary_content',
documentOid: DOCUMENT_OID,
primaryFile: {
key: 'workspace/other/specification.pdf',
name: 'specification.pdf',
size: 3,
type: 'application/pdf',
},
})
)
expect(response.status).toBe(404)
expect(mockDownloadServableFileFromStorage).not.toHaveBeenCalled()
expect(mockUploadWindchillContent).not.toHaveBeenCalled()
})
it('stores downloads as a UserFile instead of returning inline bytes', async () => {
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_download_primary_content',
documentOid: DOCUMENT_OID,
})
)
const data = await response.json()
expect(response.status).toBe(200)
expect(mockResolveWindchillContentUrl).toHaveBeenCalledWith(
expect.objectContaining({
contentPath: expect.stringContaining('/PrimaryContent'),
})
)
expect(mockResolveWindchillContentUrl.mock.calls[0][0].contentPath).not.toContain('$value')
expect(mockDownloadWindchillContent).toHaveBeenCalledWith(
expect.objectContaining({
url: expect.stringContaining('/WindchillGW/download'),
})
)
expect(mockUploadCopilotFile).toHaveBeenCalledWith(
expect.objectContaining({
buffer: Buffer.from('pdf'),
fileName: 'specification.pdf',
contentType: 'application/pdf',
userId: 'user-1',
})
)
expect(data.output.file).toMatchObject({ key: 'copilot/specification.pdf' })
expect(data.output.content).toBeUndefined()
})
it('downloads an attachment through its document-scoped content path', async () => {
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_download_attachment',
documentOid: DOCUMENT_OID,
attachmentOid: 'OR:wt.content.ApplicationData:2',
})
)
expect(response.status).toBe(200)
expect(mockResolveWindchillContentUrl).toHaveBeenCalledWith(
expect.objectContaining({
contentPath: expect.stringContaining("/Attachments('OR%3Awt.content.ApplicationData%3A2')"),
})
)
expect(mockDownloadWindchillContent).toHaveBeenCalledWith(
expect.objectContaining({
url: expect.stringContaining('/WindchillGW/download'),
})
)
})
it('uses execution storage derived from the bound delegation principal', async () => {
mockBindDelegation.mockResolvedValueOnce({
kind: 'delegated',
serviceId: 'executor',
subjectUserId: 'user-1',
workspaceId: '550e8400-e29b-41d4-a716-446655440000',
delegationId: 'delegation-1',
audience: 'sim:windchill',
issuedAt: new Date('2026-01-01T00:00:00.000Z'),
expiresAt: new Date('2027-01-01T00:00:00.000Z'),
delegationContext: {
kind: 'workflow_execution',
workflowId: '550e8400-e29b-41d4-a716-446655440001',
executionId: 'execution-1',
},
})
mockUploadExecutionFile.mockResolvedValueOnce({
id: 'file-2',
name: 'specification.pdf',
url: '/api/files/serve?key=execution/specification.pdf',
size: 3,
type: 'application/pdf',
key: 'execution/specification.pdf',
})
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_download_primary_content',
documentOid: DOCUMENT_OID,
workspaceId: 'forged-workspace',
workflowId: 'forged-workflow',
executionId: 'forged-execution',
})
)
expect(response.status).toBe(200)
expect(mockUploadExecutionFile).toHaveBeenCalledWith(
{
workspaceId: '550e8400-e29b-41d4-a716-446655440000',
workflowId: '550e8400-e29b-41d4-a716-446655440001',
executionId: 'execution-1',
},
Buffer.from('pdf'),
'specification.pdf',
'application/pdf',
'user-1'
)
expect(mockUploadCopilotFile).not.toHaveBeenCalled()
})
it('preserves sanitized provider status codes', async () => {
mockWindchillMutationRequest.mockRejectedValueOnce(
new MockWindchillProviderError('Windchill rejected the transition', 409)
)
const response = await POST(
createMockRequest('POST', {
...BASE_BODY,
operation: 'windchill_set_lifecycle_state',
documentOid: DOCUMENT_OID,
stateValue: 'RELEASED',
stateDisplay: 'Released',
})
)
expect(response.status).toBe(409)
expect(await response.json()).toEqual({
success: false,
error: 'Windchill rejected the transition',
})
})
})
+641
View File
@@ -0,0 +1,641 @@
import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import type {
WindchillOperationBody,
WindchillOperationResponse,
} from '@/lib/api/contracts/tools/windchill'
import { windchillOperationContract } from '@/lib/api/contracts/tools/windchill'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import {
createInternalSessionOrExecutorAuth,
InternalUnauthenticatedError,
} from '@/lib/api/server/routes'
import { generateRequestId } from '@/lib/core/utils/request'
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { uploadCopilotFile } from '@/lib/uploads/contexts/copilot'
import { uploadExecutionFile } from '@/lib/uploads/contexts/execution'
import type { RawFileInput } from '@/lib/uploads/utils/file-schemas'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import { sanitizeFileName } from '@/executor/constants'
import type { UserFile } from '@/executor/types'
import {
encodeWindchillOid,
normalizeServiceRoot,
normalizeWindchillDocument,
normalizeWindchillDocuments,
sanitizeWindchillError,
} from '@/tools/windchill/utils'
import {
createWindchillSession,
downloadWindchillContent,
resolveWindchillContentUrl,
uploadWindchillContent,
WindchillProviderError,
type WindchillUploadFile,
windchillDocumentUrl,
windchillMutationRequest,
} from '@/tools/windchill/utils.server'
export const dynamic = 'force-dynamic'
export const maxDuration = 900
const logger = createLogger('WindchillAPI')
const windchillSessionOrExecutorAuth = createInternalSessionOrExecutorAuth({
audience: 'sim:windchill',
})
async function authenticateWindchillExecutor(
request: NextRequest
): Promise<WorkflowExecutionDelegatedPrincipal> {
const principal = await windchillSessionOrExecutorAuth.authenticate(request, {})
if (
principal.kind !== 'delegated' ||
principal.serviceId !== 'executor' ||
!('delegationContext' in principal)
) {
throw new InternalUnauthenticatedError('Authentication required')
}
return principal
}
type WindchillRouteOutput = Extract<WindchillOperationResponse, { success: true }>['output']
type MutationOperation = Exclude<
WindchillRouteOutput['operation'],
| 'windchill_download_attachment'
| 'windchill_download_primary_content'
| 'windchill_upload_attachments'
| 'windchill_upload_primary_content'
>
const BULK_RESULT_OPERATIONS = [
'windchill_create_documents',
'windchill_update_documents',
'windchill_check_out_documents',
'windchill_check_in_documents',
'windchill_undo_check_out_documents',
'windchill_revise_documents',
'windchill_update_document_security_labels',
] as const satisfies readonly MutationOperation[]
const DELETE_OPERATIONS = [
'windchill_delete_document',
'windchill_delete_documents',
] as const satisfies readonly MutationOperation[]
type BulkResultOperation = (typeof BULK_RESULT_OPERATIONS)[number]
type DeleteOperation = (typeof DELETE_OPERATIONS)[number]
function isBulkResultOperation(operation: MutationOperation): operation is BulkResultOperation {
return BULK_RESULT_OPERATIONS.includes(operation as BulkResultOperation)
}
function isDeleteOperation(operation: MutationOperation): operation is DeleteOperation {
return DELETE_OPERATIONS.includes(operation as DeleteOperation)
}
function successResponse(output: WindchillRouteOutput) {
const body = { success: true, output } satisfies WindchillOperationResponse
return NextResponse.json(body)
}
function failureResponse(error: string, status: number) {
const body = {
success: false,
error: sanitizeWindchillError(error),
} satisfies WindchillOperationResponse
return NextResponse.json(body, { status })
}
function documentsById(documentOids: string[]) {
return documentOids.map((ID) => ({ ID }))
}
/** Keeps the media type and drops any `; charset=...` parameters Windchill cannot use. */
function safeMimeType(value: string | undefined): string {
const mediaType = value?.split(';', 1)[0]?.trim()
if (mediaType && /^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+$/.test(mediaType)) {
return mediaType
}
return 'application/octet-stream'
}
function mutationOutput(
operation: MutationOperation,
data: unknown,
fallbackIds: string[]
): WindchillRouteOutput {
const documents = normalizeWindchillDocuments(data)
const document = documents[0] ?? normalizeWindchillDocument(data)
const collectionIds = documents
.map((item) => item.id)
.filter((id): id is string => typeof id === 'string')
const returnedIds =
document?.id && !collectionIds.includes(document.id)
? [document.id, ...collectionIds]
: collectionIds
const affectedIds = returnedIds.length > 0 ? returnedIds : fallbackIds
if (isDeleteOperation(operation)) return { operation, affectedIds: fallbackIds }
if (isBulkResultOperation(operation)) {
return {
operation,
affectedIds,
...(documents.length > 0 ? { documents } : {}),
}
}
return {
operation,
affectedIds,
...(document ? { document } : {}),
}
}
async function executeMutation(
body: Exclude<
WindchillOperationBody,
| { operation: 'windchill_download_primary_content' }
| { operation: 'windchill_upload_primary_content' }
| { operation: 'windchill_download_attachment' }
| { operation: 'windchill_upload_attachments' }
>,
signal: AbortSignal
): Promise<WindchillRouteOutput> {
const session = await createWindchillSession(body, signal)
const root = normalizeServiceRoot(body.baseUrl)
switch (body.operation) {
case 'windchill_create_document': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${root}/DocMgmt/Documents`,
method: 'POST',
body: {
...(body.attributes ?? {}),
Name: body.name,
...(body.number ? { Number: body.number } : {}),
...(body.title ? { Title: body.title } : {}),
...(body.description ? { Description: body.description } : {}),
'Context@odata.bind': `Containers('${encodeWindchillOid(body.containerOid)}')`,
...(body.folderOid
? { 'Folder@odata.bind': `Folders('${encodeWindchillOid(body.folderOid)}')` }
: {}),
},
signal,
})
return mutationOutput(body.operation, data, [])
}
case 'windchill_create_documents': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${root}/DocMgmt/CreateDocuments`,
method: 'POST',
body: {
Documents: body.documents.map((document) => ({
...(document.attributes ?? {}),
Name: document.name,
...(document.number ? { Number: document.number } : {}),
...(document.title ? { Title: document.title } : {}),
...(document.description ? { Description: document.description } : {}),
'Context@odata.bind': `Containers('${encodeWindchillOid(document.containerOid)}')`,
...(document.folderOid
? { 'Folder@odata.bind': `Folders('${encodeWindchillOid(document.folderOid)}')` }
: {}),
})),
},
signal,
})
return mutationOutput(body.operation, data, [])
}
case 'windchill_update_document': {
const data = await windchillMutationRequest({
params: body,
session,
url: windchillDocumentUrl(root, body.documentOid),
method: 'PATCH',
body: body.attributes,
signal,
})
return mutationOutput(body.operation, data, [body.documentOid])
}
case 'windchill_update_common_properties': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${windchillDocumentUrl(root, body.documentOid)}/PTC.DocMgmt.UpdateCommonProperties`,
method: 'POST',
body: { Updates: body.commonProperties },
signal,
})
return mutationOutput(body.operation, data, [body.documentOid])
}
case 'windchill_update_documents': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${root}/DocMgmt/UpdateDocuments`,
method: 'POST',
body: {
Documents: body.documents.map((document) => ({
...document.attributes,
ID: document.id,
})),
},
signal,
})
return mutationOutput(
body.operation,
data,
body.documents.map((document) => document.id)
)
}
case 'windchill_delete_document': {
const data = await windchillMutationRequest({
params: body,
session,
url: windchillDocumentUrl(root, body.documentOid),
method: 'DELETE',
signal,
})
return mutationOutput(body.operation, data, [body.documentOid])
}
case 'windchill_delete_documents': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${root}/DocMgmt/DeleteDocuments`,
method: 'POST',
body: { Documents: documentsById(body.documentOids) },
signal,
})
return mutationOutput(body.operation, data, body.documentOids)
}
case 'windchill_check_out_document': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${windchillDocumentUrl(root, body.documentOid)}/PTC.DocMgmt.CheckOut`,
method: 'POST',
body: { ...(body.checkOutNote ? { CheckOutNote: body.checkOutNote } : {}) },
signal,
})
return mutationOutput(body.operation, data, [body.documentOid])
}
case 'windchill_check_out_documents': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${root}/DocMgmt/CheckOutDocuments`,
method: 'POST',
body: {
Documents: documentsById(body.documentOids),
...(body.checkOutNote ? { CheckOutNote: body.checkOutNote } : {}),
},
signal,
})
return mutationOutput(body.operation, data, body.documentOids)
}
case 'windchill_check_in_document': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${windchillDocumentUrl(root, body.documentOid)}/PTC.DocMgmt.CheckIn`,
method: 'POST',
body: {
...(body.checkInNote ? { CheckInNote: body.checkInNote } : {}),
...(body.keepCheckedOut !== undefined ? { KeepCheckedOut: body.keepCheckedOut } : {}),
...(body.checkOutNote ? { CheckOutNote: body.checkOutNote } : {}),
},
signal,
})
return mutationOutput(body.operation, data, [body.documentOid])
}
case 'windchill_check_in_documents': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${root}/DocMgmt/CheckInDocuments`,
method: 'POST',
body: {
Documents: documentsById(body.documentOids),
...(body.checkInNote ? { CheckInNote: body.checkInNote } : {}),
...(body.keepCheckedOut !== undefined ? { KeepCheckedOut: body.keepCheckedOut } : {}),
...(body.checkOutNote ? { CheckOutNote: body.checkOutNote } : {}),
},
signal,
})
return mutationOutput(body.operation, data, body.documentOids)
}
case 'windchill_undo_check_out_document': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${windchillDocumentUrl(root, body.documentOid)}/PTC.DocMgmt.UndoCheckOut`,
method: 'POST',
body: {},
signal,
})
return mutationOutput(body.operation, data, [body.documentOid])
}
case 'windchill_undo_check_out_documents': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${root}/DocMgmt/UndoCheckOutDocuments`,
method: 'POST',
body: { Documents: documentsById(body.documentOids) },
signal,
})
return mutationOutput(body.operation, data, body.documentOids)
}
case 'windchill_revise_document': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${windchillDocumentUrl(root, body.documentOid)}/PTC.DocMgmt.Revise`,
method: 'POST',
body: { ...(body.versionId ? { VersionId: body.versionId } : {}) },
signal,
})
return mutationOutput(body.operation, data, [body.documentOid])
}
case 'windchill_revise_documents': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${root}/DocMgmt/ReviseDocuments`,
method: 'POST',
body: {
Documents: documentsById(body.documentOids),
},
signal,
})
return mutationOutput(body.operation, data, body.documentOids)
}
case 'windchill_set_lifecycle_state': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${windchillDocumentUrl(root, body.documentOid)}/PTC.DocMgmt.SetState`,
method: 'POST',
body: { State: { Display: body.stateDisplay, Value: body.stateValue } },
signal,
})
return mutationOutput(body.operation, data, [body.documentOid])
}
case 'windchill_update_document_security_labels': {
const data = await windchillMutationRequest({
params: body,
session,
url: `${root}/DocMgmt/EditDocumentsSecurityLabels`,
method: 'POST',
body: {
Documents: body.securityLabelUpdates.map((update) => ({
...update.labels,
ID: update.id,
})),
},
signal,
})
return mutationOutput(
body.operation,
data,
body.securityLabelUpdates.map((update) => update.id)
)
}
}
}
async function loadUploadFiles(
inputs: RawFileInput[],
userId: string,
requestId: string
): Promise<WindchillUploadFile[] | NextResponse> {
let userFiles: UserFile[]
try {
userFiles = processFilesToUserFiles(inputs, requestId, logger)
} catch (error) {
return failureResponse(getErrorMessage(error, 'Invalid file input'), 400)
}
if (userFiles.length !== inputs.length) return failureResponse('Invalid file input', 400)
const declaredTotal = userFiles.reduce((total, file) => total + file.size, 0)
if (declaredTotal > MAX_FILE_SIZE) {
return failureResponse('Combined Windchill upload exceeds the maximum file size', 413)
}
const files: WindchillUploadFile[] = []
let actualTotal = 0
for (const userFile of userFiles) {
const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger)
if (denied) return denied
try {
const servable = await downloadServableFileFromStorage(userFile, requestId, logger, {
maxBytes: MAX_FILE_SIZE - actualTotal,
})
actualTotal += servable.buffer.length
if (actualTotal > MAX_FILE_SIZE) {
return failureResponse('Combined Windchill upload exceeds the maximum file size', 413)
}
files.push({
name: sanitizeFileName(userFile.name),
mimeType: safeMimeType(servable.contentType || userFile.type),
size: servable.buffer.length,
buffer: servable.buffer,
})
} catch (error) {
const notReady = docNotReadyResponse(error)
if (notReady) return notReady
return failureResponse(
getErrorMessage(error, 'Failed to read uploaded file'),
isPayloadSizeLimitError(error) ? 413 : 400
)
}
}
return files
}
function contentDispositionFileName(value: string | null): string | null {
if (!value) return null
const encoded = value.match(/filename\*=UTF-8''([^;]+)/i)?.[1]
if (encoded) {
try {
return decodeURIComponent(encoded)
} catch {
return encoded
}
}
return (
value.match(/filename\s*=\s*"([^"]+)"/i)?.[1] ??
value.match(/filename\s*=\s*([^;]+)/i)?.[1]?.trim() ??
null
)
}
async function storeDownloadedFile({
principal,
buffer,
fileName,
contentType,
}: {
principal: WorkflowExecutionDelegatedPrincipal
buffer: Buffer
fileName: string
contentType: string
}): Promise<UserFile> {
const { workflowId, executionId } = principal.delegationContext
if (executionId) {
return uploadExecutionFile(
{
workspaceId: principal.workspaceId,
workflowId,
executionId,
},
buffer,
fileName,
contentType,
principal.subjectUserId
)
}
return uploadCopilotFile({
buffer,
fileName,
contentType,
userId: principal.subjectUserId,
})
}
async function executeDownload(
body: Extract<
WindchillOperationBody,
| { operation: 'windchill_download_primary_content' }
| { operation: 'windchill_download_attachment' }
>,
principal: WorkflowExecutionDelegatedPrincipal,
signal: AbortSignal
): Promise<WindchillRouteOutput> {
const documentUrl = windchillDocumentUrl(body.baseUrl, body.documentOid)
const contentPath =
body.operation === 'windchill_download_primary_content'
? `${documentUrl}/PrimaryContent`
: `${documentUrl}/Attachments('${encodeWindchillOid(body.attachmentOid)}')`
const contentUrl = await resolveWindchillContentUrl({
params: body,
contentPath,
signal,
})
const downloaded = await downloadWindchillContent({
params: body,
url: contentUrl,
maxBytes: MAX_FILE_SIZE,
signal,
})
const fallback =
body.operation === 'windchill_download_primary_content'
? 'windchill-primary-content.bin'
: 'windchill-attachment.bin'
const fileName = sanitizeFileName(
body.fileName || contentDispositionFileName(downloaded.contentDisposition) || fallback
)
const mimeType = safeMimeType(downloaded.contentType)
const file = await storeDownloadedFile({
principal,
buffer: downloaded.buffer,
fileName,
contentType: mimeType,
})
return {
operation: body.operation,
file: { ...file },
fileName,
mimeType,
}
}
export const POST = withRouteHandler(
async (request: NextRequest) => {
const requestId = generateRequestId()
let principal: WorkflowExecutionDelegatedPrincipal
try {
principal = await authenticateWindchillExecutor(request)
} catch (error) {
if (error instanceof InternalUnauthenticatedError) {
return failureResponse(error.message, 401)
}
throw error
}
const parsed = await parseRequest(
windchillOperationContract,
request,
{},
{
validationErrorResponse: (error) =>
failureResponse(getValidationErrorMessage(error, 'Invalid Windchill request'), 400),
invalidJsonResponse: () =>
failureResponse('Windchill request body must be valid JSON', 400),
payloadTooLargeResponse: () => failureResponse('Windchill request body is too large', 413),
}
)
if (!parsed.success) return parsed.response
const body = parsed.data.body
try {
if (
body.operation === 'windchill_download_primary_content' ||
body.operation === 'windchill_download_attachment'
) {
return successResponse(await executeDownload(body, principal, request.signal))
}
if (
body.operation === 'windchill_upload_primary_content' ||
body.operation === 'windchill_upload_attachments'
) {
const inputs =
body.operation === 'windchill_upload_primary_content'
? [body.primaryFile]
: body.attachmentFiles
const files = await loadUploadFiles(inputs, principal.subjectUserId, requestId)
if (files instanceof NextResponse) return files
const uploadedFileNames = await uploadWindchillContent({
params: body,
documentOid: body.documentOid,
files,
primaryContent: body.operation === 'windchill_upload_primary_content',
signal: request.signal,
})
return successResponse({
operation: body.operation,
affectedIds: [body.documentOid],
uploadedFileNames,
})
}
return successResponse(await executeMutation(body, request.signal))
} catch (error) {
logger.error('Windchill operation failed', {
operation: body.operation,
error: sanitizeWindchillError(getErrorMessage(error, 'Windchill operation failed')),
})
if (error instanceof WindchillProviderError) {
const status = error.status >= 400 && error.status <= 599 ? error.status : 502
return failureResponse(error.message, status)
}
return failureResponse(
getErrorMessage(error, 'Windchill operation failed'),
isPayloadSizeLimitError(error) ? 413 : 500
)
}
},
{
unhandledErrorResponse: () => failureResponse('Windchill operation failed', 500),
}
)
@@ -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
+48
View File
@@ -0,0 +1,48 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { v2Error } from '@/app/api/v2/lib/response'
describe('v2Error retry guidance', () => {
it('sends Retry-After on 503 so a client does not retry a degraded dependency immediately', () => {
const response = v2Error('SERVICE_UNAVAILABLE', 'Service temporarily unavailable')
expect(response.status).toBe(503)
const retryAfter = response.headers.get('Retry-After')
expect(retryAfter).not.toBeNull()
expect(Number(retryAfter)).toBeGreaterThan(0)
expect(Number.isInteger(Number(retryAfter))).toBe(true)
})
it('lets a caller-supplied Retry-After win over the default', () => {
const response = v2Error('SERVICE_UNAVAILABLE', 'Service temporarily unavailable', {
headers: { 'Retry-After': '30' },
})
expect(response.headers.get('Retry-After')).toBe('30')
})
it('does not invent Retry-After for failures a retry cannot fix', () => {
for (const code of ['BAD_REQUEST', 'NOT_FOUND', 'FORBIDDEN', 'CONFLICT'] as const) {
expect(v2Error(code, 'nope').headers.get('Retry-After')).toBeNull()
}
})
it('does not default Retry-After on 429, whose wait comes from the token bucket', () => {
expect(v2Error('RATE_LIMITED', 'API rate limit exceeded').headers.get('Retry-After')).toBeNull()
})
it('stays silent on retrying when the outcome is unknown rather than absent', () => {
const response = v2Error(
'SERVICE_UNAVAILABLE',
'Async execution queue acceptance unconfirmed',
{
omitRetryAfter: true,
}
)
expect(response.status).toBe(503)
expect(response.headers.get('Retry-After')).toBeNull()
})
})
+89 -3
View File
@@ -2,6 +2,8 @@ import { NextResponse } from 'next/server'
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,
@@ -59,6 +61,44 @@ const V2_CODE_BY_HTTP_STATUS: Partial<Record<number, V2ErrorCode>> = Object.from
*/
const PRIVATE_NO_STORE = { 'Cache-Control': 'private, no-store' } as const
/**
* Seconds a caller should wait before retrying a transient v2 failure, for the
* statuses whose response carries no other timing signal.
*
* Keyed on the response status rather than the v2 error code because
* `Retry-After` is defined against the status, and the status is the only half
* of the pair a client actually sees. `v2Error` lets a caller override the
* status independently of the code, so keying on the code would let the two
* disagree.
*
* RFC 9110 §10.2.3 singles out 503 as the status whose `Retry-After` means "how
* long the service is expected to be unavailable to the client", and §15.6.4
* permits one. Note the requirement level is `MAY`, so this is a deliberate
* improvement on the baseline rather than a conformance fix: without it a
* client's only defensible policy on a 503 is an immediate retry, which is
* exactly the traffic a degraded dependency cannot absorb. Sim raises 503 when
* the API-key store, the rollout gate, the rate-limit backend, or
* execution-identity allocation is briefly unavailable, and all four are made
* worse by an unthrottled retry storm.
*
* 429 is deliberately absent because every 429 already knows its own wait: the
* throttle path measures it from the caller's token bucket
* ({@link v2RateLimitError}), and an admission denial carries the descriptor's
* declared `retryAfterSeconds` through to the route. Defaulting it here would
* paper over a path that had simply dropped its value — which is exactly the
* bug that used to leave a concurrency denial with no `Retry-After` at all.
*
* The value is Sim's one transient-failure floor, shared with the admission
* descriptors so the execute route's capacity 429 and every other surface's 503
* cannot drift apart. It is a floor, not a schedule: a fleet that retries at
* exactly this offset re-converges into a single burst, so callers should still
* add jitter — `backoffWithJitter` from `@sim/utils/retry` is what Sim's own
* clients use.
*/
const RETRY_AFTER_SECONDS_BY_STATUS: Partial<Record<number, number>> = {
503: ADMISSION_RETRY_AFTER_SECONDS,
}
type RateLimitHeaderSource = Pick<RateLimitResult, 'limit' | 'remaining' | 'resetAt'>
export function rateLimitHeaders(rateLimit?: RateLimitHeaderSource): Record<string, string> {
@@ -80,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(
@@ -104,6 +159,19 @@ interface V2ErrorOptions {
status?: number
details?: unknown
headers?: Record<string, string>
/**
* Suppresses the code's default `Retry-After` for a failure whose outcome is
* *unknown* rather than *absent*.
*
* A 503 normally means the work did not happen, so "come back in 5 seconds"
* is safe advice. The async enqueue that could not be confirmed
* (`ASYNC_ENQUEUE_AMBIGUOUS`) is the exception: it deliberately retains its
* execution-ID claim because a job may already exist. Telling that caller to
* retry invites a client with no `X-Run-Id` to start a second run of the same
* workflow, which bills twice. It must reconcile against the run id the
* response returns instead, so the response stays silent on retrying.
*/
omitRetryAfter?: boolean
}
/** `{ error: { code, message, details? } }`. */
@@ -114,11 +182,19 @@ export function v2Error(
): NextResponse {
const error: { code: V2ErrorCode; message: string; details?: unknown } = { code, message }
if (options.details !== undefined) error.details = options.details
const status = options.status ?? STATUS_BY_CODE[code]
const retryAfterSeconds = options.omitRetryAfter
? undefined
: RETRY_AFTER_SECONDS_BY_STATUS[status]
return NextResponse.json(
{ error },
{
status: options.status ?? STATUS_BY_CODE[code],
headers: { ...PRIVATE_NO_STORE, ...options.headers },
status,
headers: {
...PRIVATE_NO_STORE,
...(retryAfterSeconds === undefined ? {} : { 'Retry-After': retryAfterSeconds.toString() }),
...options.headers,
},
}
)
}
@@ -332,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
+12 -1
View File
@@ -90,7 +90,7 @@ const skill = {
updatedAt: new Date('2026-01-02T00:00:00Z'),
}
function request(method: 'GET' | 'POST', url: string, body?: unknown) {
function request(method: 'GET' | 'POST' | 'HEAD', url: string, body?: unknown) {
return new NextRequest(`http://localhost:3000${url}`, {
method,
headers: {
@@ -179,6 +179,17 @@ describe('/api/v2/skills', () => {
expect(mocks.list).not.toHaveBeenCalled()
})
/**
* The guard itself is unit-tested in `definition.test.ts`; this proves the
* pairing end-to-end, on a real v2 read that used to reply 500 to a plain HEAD.
*/
it('serves HEAD through the GET handler instead of throwing', async () => {
const response = await GET(request('HEAD', `/api/v2/skills?workspaceId=${WORKSPACE_ID}`))
expect(response.status).toBe(200)
expect(mocks.list).toHaveBeenCalled()
})
it('rejects a malformed cursor rather than silently restarting at page one', async () => {
const response = await GET(
request('GET', `/api/v2/skills?workspaceId=${WORKSPACE_ID}&cursor=not-a-cursor`)
+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,
},
}),
})
@@ -536,6 +536,39 @@ describe('POST /api/v2/workflows/[id]/execute', () => {
expect((await res.json()).error.code).toBe('RATE_LIMITED')
})
it('tells a client how long to wait when a dependency is briefly unavailable', async () => {
mockPreprocessExecution.mockResolvedValue({
success: false,
error: {
message: 'Workflow execution identity is temporarily unavailable',
statusCode: 503,
},
})
const res = await callExecute({ input: {} })
expect(res.status).toBe(503)
expect(Number(res.headers.get('Retry-After'))).toBeGreaterThan(0)
})
it('never advises a retry when an enqueue may already have started a run', async () => {
mockPreprocessExecution.mockResolvedValue({
success: false,
error: {
message: 'Async execution queue acceptance could not be confirmed',
statusCode: 503,
code: 'ASYNC_ENQUEUE_AMBIGUOUS',
},
})
const res = await callExecute({ input: {} })
expect(res.status).toBe(503)
// Retrying without X-Run-Id would start, and bill, a second run of the same workflow.
expect(res.headers.get('Retry-After')).toBeNull()
expect((await res.json()).error.details.code).toBe('ASYNC_ENQUEUE_AMBIGUOUS')
})
it('runs the anonymous public path sync but refuses async', async () => {
dbChainMockFns.limit.mockReset()
dbChainMockFns.limit.mockResolvedValueOnce([
@@ -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'
@@ -83,6 +84,8 @@ function serviceFailureResponse(failure: ExecuteWorkflowServiceFailure) {
return v2Error(code, isRunIdConflict ? 'Run ID has already been used' : failure.message, {
status: failure.statusCode,
headers,
/** An unconfirmed enqueue may already have started a run — reconcile on `runId`, never retry blind. */
omitRetryAfter: failure.code === 'ASYNC_ENQUEUE_AMBIGUOUS',
details:
detailCode || failure.executionId
? {
@@ -272,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')
})
})
@@ -42,11 +42,12 @@ import {
getDisplayName,
hasErrorInTree,
hasUnhandledErrorInTree,
iconColorClass,
isIterationType,
parseTime,
} from '@/app/workspace/[workspaceId]/logs/components/log-details/utils'
import { BlockTile } from '@/blocks/block-tile'
import { isCustomBlockType } from '@/blocks/custom/build-config'
import { getTileIconColorClass } from '@/blocks/icon-color'
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
const DEFAULT_TREE_PANE_WIDTH = 240
@@ -331,12 +332,12 @@ const TraceTreeRow = memo(function TraceTreeRow({
<div className='size-[14px] flex-shrink-0' />
)}
{!isIterationType(span.type) && (
<div
className='flex size-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm [&_img]:size-full'
style={{ background: bgColor }}
>
{BlockIcon && <BlockIcon className={cn('size-[10px]', iconColorClass(bgColor))} />}
</div>
<BlockTile
blockType={span.type?.toLowerCase() ?? ''}
icon={BlockIcon ?? undefined}
bgColor={bgColor}
size='sm'
/>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
@@ -711,7 +712,9 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa
className='mt-[2px] flex size-[18px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm [&_img]:size-full'
style={{ background: bgColor }}
>
{BlockIcon && <BlockIcon className={cn('size-[12px]', iconColorClass(bgColor))} />}
{BlockIcon && (
<BlockIcon className={cn('size-[12px]', getTileIconColorClass(bgColor))} />
)}
</div>
)}
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>

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