mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-31 03:47:12 +08:00
Merge remote-tracking branch 'origin/staging' into mship-fixes
# Conflicts: # apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx # apps/sim/lib/copilot/request/handlers/types.ts # apps/sim/lib/copilot/request/tools/files.ts # apps/sim/lib/copilot/resources/extraction.ts # apps/sim/lib/logs/log-views.ts # apps/sim/lib/table/application/views.ts # apps/sim/lib/table/views/service.test.ts # packages/logger/src/index.ts # scripts/check-api-validation-contracts.ts
This commit is contained in:
@@ -78,6 +78,43 @@ Classify each as `migrate`, `defer`, or `non-goal`. Do not migrate adjacent oper
|
||||
|
||||
Preserve behavior unless the task explicitly changes it. Stop and report a decision when surfaces currently disagree on security or compatibility behavior; do not silently choose one.
|
||||
|
||||
## Freeze observable behavior before editing
|
||||
|
||||
Treat the legacy route or tool as an ordered program, not merely a bag of business logic. Before moving code, write a compact baseline for every in-scope entry point and add focused characterization tests for behavior not already pinned down.
|
||||
|
||||
Capture all of these when they apply:
|
||||
|
||||
- Accepted inputs, including trimming, blank omission, duplicate query keys, aliases, defaults, and bounds.
|
||||
- Authentication and authorization order, minimum roles, resource membership, concealment, and exact error/status mapping.
|
||||
- Exact success bodies, optional fields, status codes, redirects, cookies, headers, and binary or stream behavior.
|
||||
- Mutation ordering, transaction boundaries, idempotency, no-ops, and observable state after each possible partial failure.
|
||||
- Audit, notification, analytics, and billing timing plus exact semantic dimensions and attribution.
|
||||
- Browser or protocol state ownership, concurrency isolation, expiry, callback ordering, and cleanup behavior.
|
||||
- Every value newly crossing into HTML, JavaScript, SQL, URLs, logs, provider payloads, or another encoding context.
|
||||
|
||||
Compare the old statement order with the proposed application lifecycle explicitly:
|
||||
|
||||
```text
|
||||
legacy parse/normalize
|
||||
-> legacy authorization checks
|
||||
-> branch-specific canonical lookup
|
||||
-> mutation(s)
|
||||
-> per-step side effects
|
||||
-> response or redirect catch
|
||||
```
|
||||
|
||||
Moving those steps under a wrapper may change behavior even when each individual call is reused. In particular:
|
||||
|
||||
- `projectAudit` and `afterSuccess` run only after `execute` returns. They cannot describe earlier committed mutations when a later step throws. Make the compound mutation atomic or define explicit partial-result/failure projection semantics before migrating it.
|
||||
- Operation metadata is executable policy. Adding a resource role to a workspace-only legacy read is an authorization change, not an architectural cleanup.
|
||||
- A shared error policy does not automatically preserve route-local concealment, subclass ordering, browser redirects, or branch-specific messages.
|
||||
- A shared contract does not automatically preserve manual `URLSearchParams` normalization or exact legacy response unions.
|
||||
- A shared use case may own domain behavior while separate surface presenters still preserve different wire shapes.
|
||||
- Per-flow identity is insufficient when another part of the flow remains in browser-global state such as one cookie.
|
||||
- Passing a newly supported parameter through old rendering code creates a new security boundary even when the renderer itself is unchanged.
|
||||
|
||||
Fail fast if the baseline cannot be established from code, tests, or an explicit product decision. Do not infer that behavior is unimportant because it was previously implicit.
|
||||
|
||||
## Keep the layers distinct
|
||||
|
||||
Use these responsibilities:
|
||||
@@ -270,6 +307,10 @@ Add focused tests for every migrated surface and principal kind allowed by the o
|
||||
- Public API: personal and workspace keys, rate and rollout behavior, concealment, exact external envelope, and rate headers.
|
||||
- Copilot or tools: trusted context, exact registered operation membership, rejected forged scope, aliases and resume paths, permission re-check, safe errors, and unchanged tool result shapes.
|
||||
- Side effects: audit derives from authoritative results; shared notifications follow audit; neither occurs for rejection or no-op.
|
||||
- Compatibility characterization: legacy normalization, exact response/redirect/cookie behavior, concealment, error subclass precedence, and branch-specific output.
|
||||
- Failure sequencing: inject a failure after each independently committing step and assert persisted state plus audit, analytics, and notification effects.
|
||||
- Concurrency: overlap stateful browser or provider flows and prove each callback consumes only its own state and return destination.
|
||||
- Rendering boundaries: exercise hostile values for every newly connected input that reaches HTML, inline JavaScript, URLs, logs, or provider requests.
|
||||
|
||||
Run at minimum:
|
||||
|
||||
|
||||
@@ -100,6 +100,16 @@ improvement(scope): description for enhancements
|
||||
chore(scope): description for maintenance
|
||||
```
|
||||
|
||||
## What to Omit
|
||||
|
||||
The repo is public. Keep the title and description to the code change and its reasoning — never:
|
||||
|
||||
- Customer, company, or user names; workspace/user/org IDs; email addresses
|
||||
- Prod or staging operational data: log lines, DB rows, metrics, timestamps, incident details, canary/alert output
|
||||
- Infrastructure specifics: hostnames, ARNs, internal URLs, env var values, secret names
|
||||
|
||||
Describe the bug by its mechanism, not by how you found it. "Expired OAuth credentials fail to refresh in the worker" — not "the Sheets canary failed at 16:31Z for workspace abc-123".
|
||||
|
||||
## PR Description Format
|
||||
|
||||
Use this exact template in the user's voice (concise, bullet points):
|
||||
|
||||
@@ -68,6 +68,10 @@ If it fails, do not add the entry to an allowlist — there isn't one. Find the
|
||||
|
||||
Run it with `--verbose` to print per-route module counts, which is also the quickest way to see whether a change moved the graph.
|
||||
|
||||
The same command also ratchets those counts against `check-tool-registry-boundary.baseline.json`. `--check` (what CI runs) fails when an entry exceeds its baseline by more than `max(25 modules, 2%)`, naming the import chain responsible. This catches bloat the registry rule misses — a prefetch importing `listTables` cost the Tables page 444 modules without ever touching `@/tools/registry`.
|
||||
|
||||
Re-record with `--update-baseline` and commit the JSON when growth is deliberate. A *shrink* passes but is reported — re-record then too, or the win is silently spendable again.
|
||||
|
||||
## How to verify an edge actually got cut
|
||||
|
||||
Do not eyeball imports — the registry is reached through several redundant paths, so cutting one buys nothing while another survives. Walk the graph:
|
||||
|
||||
@@ -19,7 +19,7 @@ Nothing else at the top level. No `success: true`, no bare `{ "error": "string"
|
||||
That promise is worth stating as a rule because it has been broken five separate ways, each time by a route or a builder taking a shortcut that looked local:
|
||||
|
||||
- `GET /workflows?limit=1.5` returned **500**. The contract was copied from a sibling and lost its `.int()`, so a fractional limit passed validation and reached Postgres as `LIMIT 2.5`.
|
||||
- A malformed JSON body returned **`{"error":"Request body must be valid JSON"}`** — a bare string. The envelope was a per-route opt-in that only 8 of 77 routes remembered.
|
||||
- A malformed JSON body returned **`{"error":"Request body must be valid JSON"}`** — a bare string. The envelope was a per-route opt-in that only 8 routes remembered.
|
||||
- `GET /api/v2/nonexistent` returned a **full HTML 404 document**, because no route file matched and the request fell through to the app's global not-found page.
|
||||
- Four collections returned `nextCursor` while **silently discarding** any `limit` the caller sent, because Zod strips unknown keys unless the schema is `.strict()`.
|
||||
- Handing back a `nextCursor` from any timestamp-sorted list and passing it straight in returned **500**. The value was validated and bound — but bound with no SQL type, into `date_trunc`, which is overloaded, so Postgres could resolve no overload. Validation was never the missing half; the type was.
|
||||
@@ -52,10 +52,10 @@ A route built with `defineV2JsonRoute` gets this for free: its `present` returns
|
||||
| 200 / 201 | — | Success. 201 only for a created resource. |
|
||||
| 400 | `BAD_REQUEST` | Contract validation. Carries field-level `details` from `serializeZodIssues`. |
|
||||
| 401 | `UNAUTHORIZED` | No/!valid API key. **Runs before the rollout gate.** |
|
||||
| 403 | `FORBIDDEN` | Authenticated, same tenant, insufficient rights. Carries a machine-readable `details.code` from the closed set in `lib/core/application/forbidden.ts` (e.g. `INSUFFICIENT_WORKSPACE_ROLE`, `PERSONAL_API_KEYS_DISABLED`). |
|
||||
| 403 | `FORBIDDEN` | Authenticated, same tenant, insufficient rights. Where the cause is one a caller can act on it is named in `details.code`, from the closed set in `lib/core/application/forbidden.ts` (e.g. `INSUFFICIENT_WORKSPACE_ROLE`, `PERSONAL_API_KEYS_DISABLED`). A few domain refusals still reach the wire without one. |
|
||||
| 404 | `NOT_FOUND` | Not found, **and** cross-tenant concealment, **and** the rollout gate, **and** an unknown path. |
|
||||
| 409 | `CONFLICT` | Uniqueness/state conflict, human-readable message. |
|
||||
| 413 | `PAYLOAD_TOO_LARGE` | Body over the route's `maxBodyBytes`. |
|
||||
| 413 | `PAYLOAD_TOO_LARGE` | Body over the route's `maxBodyBytes` — **and** a collection the response must materialize that is over *its* ceiling. Fourteen bodyless `GET`/`DELETE` operations publish it for the folder-tree cap (`FolderCollectionLimitExceededError`) or the rendered-artifact cap. |
|
||||
| 429 | `RATE_LIMITED` | With `Retry-After` and `X-RateLimit-*`. |
|
||||
| 500 | `INTERNAL_ERROR` | Genuine server fault only. Message is always generic. |
|
||||
|
||||
@@ -74,7 +74,7 @@ And this class survives a green test suite — `keysetAfter` returned well-forme
|
||||
- An operation whose `minimumRole` is `write` or `admin` can always 403 — a member with a lower role hits it. Document 403.
|
||||
- An operation whose `minimumRole` is `read` cannot 403 *that* way, because `read` is the floor of the `read < write < admin` ordering and anyone without access is concealed as 404 instead. It can still 403 through `PersonalApiKeysDisabledError` (a personal API key against a workspace whose organization disabled them) or `WorkspaceApiKeyAuthorizationError` (`workspaceApiKey: 'deny'`), and every v2 operation is reachable by a personal API key. **So in practice every workspace-scoped v2 operation documents 403**, and the reads that omitted it were wrong, not principled.
|
||||
|
||||
**Every 403 names its cause in `error.details.code`.** One status covers four different remedies — raise a member's role, issue a personal key instead of a workspace-scoped one, re-point a workspace key, buy an enterprise plan — and prose is not branchable, so a client that must tell them apart was string-matching messages, which turns every reword into a silent break.
|
||||
**A 403 a caller can act on names its cause in `error.details.code` — but not every 403 does yet.** One status covers several different remedies — raise a member's role, issue a personal key instead of a workspace-scoped one, re-point a workspace key, buy an enterprise plan, delete a resource to get under a quota — and prose is not branchable, so a client that must tell them apart was string-matching messages, which turns every reword into a silent break. A handful of domain refusals still throw a bare `OrchestrationError('forbidden', …)` and reach the wire with no code, so **write client code that treats `details.code` as optional**, and read `openapi/shared.ts`'s `FORBIDDEN_DESCRIPTION` for the current position rather than assuming the sweep is finished. For code you are *writing*, the rule below is unconditional.
|
||||
|
||||
The vocabulary is a closed set, `FORBIDDEN_DETAIL_CODES` in `lib/core/application/forbidden.ts`, with a `Record` of descriptions beside it that the generated OpenAPI 403 description is built from. Adding a member fails to compile until it is documented, so a code cannot reach the wire unpublished. Do not invent a code at a route: throw `ForbiddenOperationError(code, message)` from the domain and let `v2CaughtOrchestrationError` — the function every v2 error policy falls through to — attach it. `InsufficientWorkspacePermissionsError`, `PersonalApiKeysDisabledError`, `WorkspaceApiKeyAuthorizationError`, and `PrincipalKindAuthorizationError` already carry theirs.
|
||||
|
||||
@@ -84,6 +84,8 @@ Use the shared sets in `contracts/v2/openapi/shared.ts` — `RESOURCE_ERRORS`, `
|
||||
|
||||
**HEAD is answered by the `GET` handler, not rejected.** Next aliases a missing `HEAD` export onto `GET` and drops the body when sending, so a route's `GET` legitimately runs with `request.method === 'HEAD'`. The builders' method guard accepts that pairing via `methodMatchesContract`; any other mismatch stays a hard error. Never hand-write a `HEAD` export to "fix" this.
|
||||
|
||||
**A `GET` with side effects must declare `headSafe: false`.** The aliasing above is only sound because RFC 9110 §9.2.1 defines `HEAD` as safe. A `GET` that writes a row or opens an outbound connection is not, and an uptime monitor or link checker walking the documented URL list would drive those effects on every probe — `GET /files/{fileId}` records a `FILE_DOWNLOADED` audit event, so a `HEAD` used to fabricate a download that never happened. Both the JSON and binary builders take `headSafe`: a route that sets it `false` still authenticates and rate-limits a `HEAD`, then answers `v2HeadNoEffect()` — a bodiless 200 — before parsing or executing. Nothing observable is lost, because `HEAD` carries no body either way. Audit this whenever a read acquires an audit projection or an outbound call.
|
||||
|
||||
## Rule 3 — a collection that returns `nextCursor` must accept `limit` + `cursor`, and must apply them
|
||||
|
||||
Every list returns `{ data, nextCursor }`. Whether it *pages* is a separate, pinned decision — see `lib/api/contracts/v2/__tests__/list-pagination.test.ts`, which enumerates both sets and fails when a new list is in neither.
|
||||
@@ -96,10 +98,16 @@ Build the query slice from the shared helper, never by hand:
|
||||
|
||||
That gives `limit` (integer, 1..`V2_MAX_PAGE_SIZE`, defaulting to `V2_DEFAULT_PAGE_SIZE` = 50) and an opaque `cursor`. Re-declaring `limit: z.coerce.number()...` inline is how the 500 happened; there is one schema so the family cannot drift again.
|
||||
|
||||
Two cursor schemes exist, both opaque base64-JSON from `response.ts`. Which one you use is decided by what the read can express, not by taste:
|
||||
Three cursor schemes exist. Two are the shared codecs in `response.ts`, both opaque base64-JSON, and which of them you use is decided by what the read can express, not by taste:
|
||||
|
||||
- **Keyset** (`readSortedCursor` in, `encodeSortedCursor` out) — the default. Requires the page to come from one ordered SQL read. The sort is stamped into the cursor and re-checked on replay, so changing `sortBy` mid-pagination is a 400, not a silently skipped page.
|
||||
- **Offset** (`decodeOffsetCursor` / `encodeOffsetCursor`) — only when a keyset is impossible. Two lists qualify: `GET /skills` merges a static in-code registry with DB rows and re-sorts in JS, and `GET /knowledge/{id}/documents` sits on a limit/offset query. An offset cursor **must** be stamped with `offsetCursorScope(...)` covering every param that filters or orders the sequence (not `limit`, which only selects how much of it to return). A bare offset replayed against a re-sorted or re-filtered sequence names a different row, which skips or repeats results — the exact failure the keyset's sort stamp already prevents.
|
||||
- **Keyset** (`readSortedCursor` in, `encodeSortedCursor` out) — the default. Requires the page to come from one ordered SQL read. The sort AND the filters are stamped into the cursor and re-checked on replay, so changing `sortBy` or any filter mid-pagination is a 400, not a silently skipped page.
|
||||
- **Offset** (`decodeOffsetCursor` / `encodeOffsetCursor`) — only when a keyset is impossible. Two lists qualify: `GET /skills` merges a static in-code registry with DB rows and re-sorts in JS, and `GET /knowledge/{id}/documents` sits on a limit/offset query. A bare offset replayed against a re-sorted or re-filtered sequence names a different row, which skips or repeats results.
|
||||
|
||||
Both take the same two stamps: `cursorSortKey(sortBy, sortOrder)` for the ordering, and `cursorFilterScope({ ... })` for every param that filters the sequence. **`limit` is never a stamp** — it selects how much of the sequence to return, not what the sequence is, and binding it strands every cursor the moment a caller changes page size. Params that only shape the response body are out for the same reason.
|
||||
|
||||
The third is **per-domain**: a list whose read predates the shared codecs, or whose page boundary is not expressible as one, mints its own — a bare `encodeCursor({ version })` on `GET /workflows/{id}/versions` and `encodeCursor({ email })` on the workspace member list, the local codecs in `lib/audit-logs/query.ts`, `lib/logs/list-logs.ts`, and `lib/table/rows/cursor.ts`, and a usage-event id passed straight through by `GET /billing/logs`. Those tokens stay opaque and untouched, but a domain-minted cursor on a list a caller can re-filter is wrapped at the surface with `encodeScopedCursor(cursorFilterScope({...}), token)` and unwrapped with `readScopedCursor`, so it carries the same binding as the shared schemes. **A new list picks one of the two shared schemes.** Do not add a fourth.
|
||||
|
||||
Every paged list's binding is declared in `lib/api/contracts/v2/__tests__/list-pagination.test.ts` and checked against what the contract actually accepts, in both directions. A new list, or a new filter on an existing one, fails that test until its binding is declared or the param is explicitly recorded as unable to change the sequence.
|
||||
|
||||
**A keyset's key list must end in a unique column (`id`).** A non-unique trailing key cannot separate tied rows, so the page boundary either repeats or drops them. `lib/api/list-keyset-paging.test.ts` demonstrates the failure.
|
||||
|
||||
@@ -111,6 +119,10 @@ Return `nextCursor: null` on the last page and only then. Never construct a curs
|
||||
|
||||
## Rule 4 — reject what you do not implement
|
||||
|
||||
**Every contract declares a `query`, even when the endpoint takes none** — `query: noInputSchema` (`z.object({}).strict()`), never omission. `parseRequest` validates the query slice only when the contract declares one, so an omitted `query` means "never look at the query string", not "takes no query params". The two were indistinguishable, which is how 69 contracts ended up accepting anything without anyone deciding they should: `GET /workflows/{id}?bogus=1` answered 200 while every list answered 400 for the same shape. `query-declaration.test.ts` sweeps the tree so contract 70 fails at authoring time rather than shipping unvalidated.
|
||||
|
||||
Declaring them is a **deliberate tightening** of endpoints that previously ignored an unknown param. It was weighed and kept: the v2 body slice on those same endpoints was already strict, so the split was arbitrary rather than a promise to callers; a mistyped param that is silently dropped is the bug class this rule exists to prevent; and no first-party caller sends an undeclared v2 query param (the two SDKs send only `includeOutput`/`selectedOutputs`, both declared; the UI makes no v2 calls at all; `requestJson` appends nothing implicitly and no v2 cache buster exists). Third-party callers appending a tracking tag or cache buster do break, which is why `api-reference/getting-started.mdx` documents the behavior rather than leaving it to be discovered from a 400.
|
||||
|
||||
Query and body schemas are **`.strict()`** — and `.strict()` binds the **top level only**. A strict body containing a non-strict nested object still drops unknown keys one level down, which is the headline `filter` bug at a smaller scale: `sort: [{ field, direction, nulls: 'last' }]` answered 200 and ordered by the default. Strictness belongs on the shared nested schema (`sortSpecSchema`'s element, `tableViewConfigSchema`), not restated per body.
|
||||
|
||||
Before tightening a schema that is **also** a response or a stored blob, make the read canonical first. `table_views.config` is schemaless JSONB, so a legacy row carrying a retired key would fail a newly strict response parse and become a 500; `normalizeStoredViewConfig` projects the stored blob onto the declared keys so the tightening is safe in both directions. Zod strips unknown keys by default, so a non-strict schema answers `?limit=1` with 200 and the whole set — the caller believes it bounded the response and it did not. That is a contract lie, and on an uncapped list it is also an unbounded-response risk.
|
||||
@@ -132,7 +144,7 @@ That last one is the standard to aim for. A message that only says `Invalid inpu
|
||||
Order matters because each layer is checked against the one before it.
|
||||
|
||||
1. **Contract** in `lib/api/contracts/v2/<domain>.ts` via `defineRouteContract`. Response schemas are `.parse`d on the way out, so a field the producer does not actually emit becomes a 500 on a successful read — assert only what you can prove.
|
||||
2. **Application use case** owns canonical loading, authorization, business behavior, and audit. The route's `present` receives **only the use-case result**, so anything the presenter needs (e.g. the active `sortBy`/`sortOrder` to stamp a cursor) must be returned by the use case.
|
||||
2. **Application use case** owns canonical loading, authorization, business behavior, and audit. The route's `present` receives the use-case result **and the parsed request**, so a presenter reads request params (the active `sortBy`/`sortOrder` and filters it stamps into a cursor) straight from `query`/`params` rather than making the use case carry an HTTP concern back out.
|
||||
3. **Route** with `defineV2JsonRoute`, declaring `contract`, `auth: v2ApiKeyAuth`, `operation`, `rateLimit`, `errorPolicy`, `mapInput`, `useCase`, `present`. Auth and rate limiting run before parsing.
|
||||
4. **OpenAPI description** in `lib/api/contracts/v2/openapi/<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.
|
||||
|
||||
@@ -153,7 +165,7 @@ Do not add a default for any other code. 400/403/404/409 are not fixed by waitin
|
||||
|
||||
**A failure whose outcome is unknown must not advise a retry.** `ASYNC_ENQUEUE_AMBIGUOUS` is a 503 whose enqueue may have succeeded — it deliberately retains its execution-ID claim. Telling that caller to come back in 5 seconds invites a client with no `X-Run-Id` to start and bill a second run. It passes `omitRetryAfter: true` and returns the run id so the caller reconciles instead. Any future "we don't know if it happened" failure does the same.
|
||||
|
||||
RFC 9110 §10.2.3 gives 503 this field's clearest meaning — "how long the service is expected to be unavailable to the client". Note the requirement level is only `MAY`, on 503 (§15.6.4) and, via RFC 6585 §4, on 429. It is `SHOULD` on exactly one status, 413, and only when the condition is temporary; Sim's 413 is a fixed byte ceiling, so it correctly sends none.
|
||||
RFC 9110 §10.2.3 gives 503 this field's clearest meaning — "how long the service is expected to be unavailable to the client". Note the requirement level is only `MAY`, on 503 (§15.6.4) and, via RFC 6585 §4, on 429. It is `SHOULD` on exactly one status, 413, and only when the condition is temporary; none of Sim's 413s are temporary — they are fixed ceilings, on the request body and on the collections a response must materialize — so it correctly sends none.
|
||||
|
||||
## Deliberate non-adoptions
|
||||
|
||||
@@ -186,7 +198,7 @@ That makes the money path safe against double-execution **for callers that opt i
|
||||
The base64-JSON cursor is **not signed**, and does not need to be. Tampering is bounded by construction, and that is a property to preserve:
|
||||
|
||||
- Every key value is re-validated by its `KeysetKey.bind`, which returns `null` for a wrong-typed or unparseable value and becomes a 400. A forged cursor cannot reach SQL as `NaN` or an `Invalid Date`.
|
||||
- The sort is stamped into the cursor and re-checked (`decodeSortedCursor`), so a cursor from a differently-sorted query is a 400, not a silently skipped page.
|
||||
- The sort and a fingerprint of the filters are stamped into the cursor and re-checked (`decodeSortedCursor`), so a cursor from a differently-sorted or differently-filtered query is a 400, not a silently skipped page. The filters are hashed (SHA-256, via `lib/api/cursor-binding.ts`) rather than embedded, so the token stays short and a caller cannot cheaply construct a filter that collides with another sequence's stamp.
|
||||
- The offset codec rejects anything that is not a non-negative integer.
|
||||
- Authorization is **never** carried in the cursor. Every list re-derives its workspace scope from the authenticated principal, so a cursor lifted from another query — or another tenant — can only move the caller within their own authorized result set.
|
||||
|
||||
@@ -199,9 +211,11 @@ Run this against any new or changed v2 endpoint.
|
||||
- [ ] Success body is exactly `{data}` or `{data, nextCursor}`; failures are exactly `{error:{code,message,details?}}`.
|
||||
- [ ] Route uses a shared builder; no hand-built `NextResponse.json`.
|
||||
- [ ] Query and body schemas are `.strict()`.
|
||||
- [ ] The contract declares a `query` — `noInputSchema` when the endpoint takes no query params, never omission.
|
||||
- [ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer, and that any bound value passed as an argument to a SQL function carries an explicit type.
|
||||
- [ ] `limit` comes from `v2PaginationFields`, not a hand-written `z.coerce.number()`.
|
||||
- [ ] If the response carries `nextCursor`, the query accepts `limit` + `cursor` and the query actually applies them.
|
||||
- [ ] The cursor is bound to every param that filters or orders the sequence, and to none that do not (never `limit`), with the binding declared in `list-pagination.test.ts`.
|
||||
- [ ] Keyset sorts end in a unique `id` key.
|
||||
- [ ] The list is classified in `list-pagination.test.ts`.
|
||||
- [ ] Cross-tenant access answers 404, never 403 — and carries `Cache-Control: private, no-store`, because RFC 9110 §15.5.5 makes 404 heuristically cacheable and an authorization-dependent 404 must never be stored. `v2Error` sets this unconditionally; do not build a v2 response any other way.
|
||||
|
||||
@@ -77,6 +77,43 @@ Classify each as `migrate`, `defer`, or `non-goal`. Do not migrate adjacent oper
|
||||
|
||||
Preserve behavior unless the task explicitly changes it. Stop and report a decision when surfaces currently disagree on security or compatibility behavior; do not silently choose one.
|
||||
|
||||
## Freeze observable behavior before editing
|
||||
|
||||
Treat the legacy route or tool as an ordered program, not merely a bag of business logic. Before moving code, write a compact baseline for every in-scope entry point and add focused characterization tests for behavior not already pinned down.
|
||||
|
||||
Capture all of these when they apply:
|
||||
|
||||
- Accepted inputs, including trimming, blank omission, duplicate query keys, aliases, defaults, and bounds.
|
||||
- Authentication and authorization order, minimum roles, resource membership, concealment, and exact error/status mapping.
|
||||
- Exact success bodies, optional fields, status codes, redirects, cookies, headers, and binary or stream behavior.
|
||||
- Mutation ordering, transaction boundaries, idempotency, no-ops, and observable state after each possible partial failure.
|
||||
- Audit, notification, analytics, and billing timing plus exact semantic dimensions and attribution.
|
||||
- Browser or protocol state ownership, concurrency isolation, expiry, callback ordering, and cleanup behavior.
|
||||
- Every value newly crossing into HTML, JavaScript, SQL, URLs, logs, provider payloads, or another encoding context.
|
||||
|
||||
Compare the old statement order with the proposed application lifecycle explicitly:
|
||||
|
||||
```text
|
||||
legacy parse/normalize
|
||||
-> legacy authorization checks
|
||||
-> branch-specific canonical lookup
|
||||
-> mutation(s)
|
||||
-> per-step side effects
|
||||
-> response or redirect catch
|
||||
```
|
||||
|
||||
Moving those steps under a wrapper may change behavior even when each individual call is reused. In particular:
|
||||
|
||||
- `projectAudit` and `afterSuccess` run only after `execute` returns. They cannot describe earlier committed mutations when a later step throws. Make the compound mutation atomic or define explicit partial-result/failure projection semantics before migrating it.
|
||||
- Operation metadata is executable policy. Adding a resource role to a workspace-only legacy read is an authorization change, not an architectural cleanup.
|
||||
- A shared error policy does not automatically preserve route-local concealment, subclass ordering, browser redirects, or branch-specific messages.
|
||||
- A shared contract does not automatically preserve manual `URLSearchParams` normalization or exact legacy response unions.
|
||||
- A shared use case may own domain behavior while separate surface presenters still preserve different wire shapes.
|
||||
- Per-flow identity is insufficient when another part of the flow remains in browser-global state such as one cookie.
|
||||
- Passing a newly supported parameter through old rendering code creates a new security boundary even when the renderer itself is unchanged.
|
||||
|
||||
Fail fast if the baseline cannot be established from code, tests, or an explicit product decision. Do not infer that behavior is unimportant because it was previously implicit.
|
||||
|
||||
## Keep the layers distinct
|
||||
|
||||
Use these responsibilities:
|
||||
@@ -269,6 +306,10 @@ Add focused tests for every migrated surface and principal kind allowed by the o
|
||||
- Public API: personal and workspace keys, rate and rollout behavior, concealment, exact external envelope, and rate headers.
|
||||
- Copilot or tools: trusted context, exact registered operation membership, rejected forged scope, aliases and resume paths, permission re-check, safe errors, and unchanged tool result shapes.
|
||||
- Side effects: audit derives from authoritative results; shared notifications follow audit; neither occurs for rejection or no-op.
|
||||
- Compatibility characterization: legacy normalization, exact response/redirect/cookie behavior, concealment, error subclass precedence, and branch-specific output.
|
||||
- Failure sequencing: inject a failure after each independently committing step and assert persisted state plus audit, analytics, and notification effects.
|
||||
- Concurrency: overlap stateful browser or provider flows and prove each callback consumes only its own state and return destination.
|
||||
- Rendering boundaries: exercise hostile values for every newly connected input that reaches HTML, inline JavaScript, URLs, logs, or provider requests.
|
||||
|
||||
Run at minimum:
|
||||
|
||||
|
||||
@@ -99,6 +99,16 @@ improvement(scope): description for enhancements
|
||||
chore(scope): description for maintenance
|
||||
```
|
||||
|
||||
## What to Omit
|
||||
|
||||
The repo is public. Keep the title and description to the code change and its reasoning — never:
|
||||
|
||||
- Customer, company, or user names; workspace/user/org IDs; email addresses
|
||||
- Prod or staging operational data: log lines, DB rows, metrics, timestamps, incident details, canary/alert output
|
||||
- Infrastructure specifics: hostnames, ARNs, internal URLs, env var values, secret names
|
||||
|
||||
Describe the bug by its mechanism, not by how you found it. "Expired OAuth credentials fail to refresh in the worker" — not "the Sheets canary failed at 16:31Z for workspace abc-123".
|
||||
|
||||
## PR Description Format
|
||||
|
||||
Use this exact template in the user's voice (concise, bullet points):
|
||||
|
||||
@@ -67,6 +67,10 @@ If it fails, do not add the entry to an allowlist — there isn't one. Find the
|
||||
|
||||
Run it with `--verbose` to print per-route module counts, which is also the quickest way to see whether a change moved the graph.
|
||||
|
||||
The same command also ratchets those counts against `check-tool-registry-boundary.baseline.json`. `--check` (what CI runs) fails when an entry exceeds its baseline by more than `max(25 modules, 2%)`, naming the import chain responsible. This catches bloat the registry rule misses — a prefetch importing `listTables` cost the Tables page 444 modules without ever touching `@/tools/registry`.
|
||||
|
||||
Re-record with `--update-baseline` and commit the JSON when growth is deliberate. A *shrink* passes but is reported — re-record then too, or the win is silently spendable again.
|
||||
|
||||
## How to verify an edge actually got cut
|
||||
|
||||
Do not eyeball imports — the registry is reached through several redundant paths, so cutting one buys nothing while another survives. Walk the graph:
|
||||
|
||||
@@ -18,7 +18,7 @@ Nothing else at the top level. No `success: true`, no bare `{ "error": "string"
|
||||
That promise is worth stating as a rule because it has been broken five separate ways, each time by a route or a builder taking a shortcut that looked local:
|
||||
|
||||
- `GET /workflows?limit=1.5` returned **500**. The contract was copied from a sibling and lost its `.int()`, so a fractional limit passed validation and reached Postgres as `LIMIT 2.5`.
|
||||
- A malformed JSON body returned **`{"error":"Request body must be valid JSON"}`** — a bare string. The envelope was a per-route opt-in that only 8 of 77 routes remembered.
|
||||
- A malformed JSON body returned **`{"error":"Request body must be valid JSON"}`** — a bare string. The envelope was a per-route opt-in that only 8 routes remembered.
|
||||
- `GET /api/v2/nonexistent` returned a **full HTML 404 document**, because no route file matched and the request fell through to the app's global not-found page.
|
||||
- Four collections returned `nextCursor` while **silently discarding** any `limit` the caller sent, because Zod strips unknown keys unless the schema is `.strict()`.
|
||||
- Handing back a `nextCursor` from any timestamp-sorted list and passing it straight in returned **500**. The value was validated and bound — but bound with no SQL type, into `date_trunc`, which is overloaded, so Postgres could resolve no overload. Validation was never the missing half; the type was.
|
||||
@@ -51,10 +51,10 @@ A route built with `defineV2JsonRoute` gets this for free: its `present` returns
|
||||
| 200 / 201 | — | Success. 201 only for a created resource. |
|
||||
| 400 | `BAD_REQUEST` | Contract validation. Carries field-level `details` from `serializeZodIssues`. |
|
||||
| 401 | `UNAUTHORIZED` | No/!valid API key. **Runs before the rollout gate.** |
|
||||
| 403 | `FORBIDDEN` | Authenticated, same tenant, insufficient rights. Carries a machine-readable `details.code` from the closed set in `lib/core/application/forbidden.ts` (e.g. `INSUFFICIENT_WORKSPACE_ROLE`, `PERSONAL_API_KEYS_DISABLED`). |
|
||||
| 403 | `FORBIDDEN` | Authenticated, same tenant, insufficient rights. Where the cause is one a caller can act on it is named in `details.code`, from the closed set in `lib/core/application/forbidden.ts` (e.g. `INSUFFICIENT_WORKSPACE_ROLE`, `PERSONAL_API_KEYS_DISABLED`). A few domain refusals still reach the wire without one. |
|
||||
| 404 | `NOT_FOUND` | Not found, **and** cross-tenant concealment, **and** the rollout gate, **and** an unknown path. |
|
||||
| 409 | `CONFLICT` | Uniqueness/state conflict, human-readable message. |
|
||||
| 413 | `PAYLOAD_TOO_LARGE` | Body over the route's `maxBodyBytes`. |
|
||||
| 413 | `PAYLOAD_TOO_LARGE` | Body over the route's `maxBodyBytes` — **and** a collection the response must materialize that is over *its* ceiling. Fourteen bodyless `GET`/`DELETE` operations publish it for the folder-tree cap (`FolderCollectionLimitExceededError`) or the rendered-artifact cap. |
|
||||
| 429 | `RATE_LIMITED` | With `Retry-After` and `X-RateLimit-*`. |
|
||||
| 500 | `INTERNAL_ERROR` | Genuine server fault only. Message is always generic. |
|
||||
|
||||
@@ -73,7 +73,7 @@ And this class survives a green test suite — `keysetAfter` returned well-forme
|
||||
- An operation whose `minimumRole` is `write` or `admin` can always 403 — a member with a lower role hits it. Document 403.
|
||||
- An operation whose `minimumRole` is `read` cannot 403 *that* way, because `read` is the floor of the `read < write < admin` ordering and anyone without access is concealed as 404 instead. It can still 403 through `PersonalApiKeysDisabledError` (a personal API key against a workspace whose organization disabled them) or `WorkspaceApiKeyAuthorizationError` (`workspaceApiKey: 'deny'`), and every v2 operation is reachable by a personal API key. **So in practice every workspace-scoped v2 operation documents 403**, and the reads that omitted it were wrong, not principled.
|
||||
|
||||
**Every 403 names its cause in `error.details.code`.** One status covers four different remedies — raise a member's role, issue a personal key instead of a workspace-scoped one, re-point a workspace key, buy an enterprise plan — and prose is not branchable, so a client that must tell them apart was string-matching messages, which turns every reword into a silent break.
|
||||
**A 403 a caller can act on names its cause in `error.details.code` — but not every 403 does yet.** One status covers several different remedies — raise a member's role, issue a personal key instead of a workspace-scoped one, re-point a workspace key, buy an enterprise plan, delete a resource to get under a quota — and prose is not branchable, so a client that must tell them apart was string-matching messages, which turns every reword into a silent break. A handful of domain refusals still throw a bare `OrchestrationError('forbidden', …)` and reach the wire with no code, so **write client code that treats `details.code` as optional**, and read `openapi/shared.ts`'s `FORBIDDEN_DESCRIPTION` for the current position rather than assuming the sweep is finished. For code you are *writing*, the rule below is unconditional.
|
||||
|
||||
The vocabulary is a closed set, `FORBIDDEN_DETAIL_CODES` in `lib/core/application/forbidden.ts`, with a `Record` of descriptions beside it that the generated OpenAPI 403 description is built from. Adding a member fails to compile until it is documented, so a code cannot reach the wire unpublished. Do not invent a code at a route: throw `ForbiddenOperationError(code, message)` from the domain and let `v2CaughtOrchestrationError` — the function every v2 error policy falls through to — attach it. `InsufficientWorkspacePermissionsError`, `PersonalApiKeysDisabledError`, `WorkspaceApiKeyAuthorizationError`, and `PrincipalKindAuthorizationError` already carry theirs.
|
||||
|
||||
@@ -83,6 +83,8 @@ Use the shared sets in `contracts/v2/openapi/shared.ts` — `RESOURCE_ERRORS`, `
|
||||
|
||||
**HEAD is answered by the `GET` handler, not rejected.** Next aliases a missing `HEAD` export onto `GET` and drops the body when sending, so a route's `GET` legitimately runs with `request.method === 'HEAD'`. The builders' method guard accepts that pairing via `methodMatchesContract`; any other mismatch stays a hard error. Never hand-write a `HEAD` export to "fix" this.
|
||||
|
||||
**A `GET` with side effects must declare `headSafe: false`.** The aliasing above is only sound because RFC 9110 §9.2.1 defines `HEAD` as safe. A `GET` that writes a row or opens an outbound connection is not, and an uptime monitor or link checker walking the documented URL list would drive those effects on every probe — `GET /files/{fileId}` records a `FILE_DOWNLOADED` audit event, so a `HEAD` used to fabricate a download that never happened. Both the JSON and binary builders take `headSafe`: a route that sets it `false` still authenticates and rate-limits a `HEAD`, then answers `v2HeadNoEffect()` — a bodiless 200 — before parsing or executing. Nothing observable is lost, because `HEAD` carries no body either way. Audit this whenever a read acquires an audit projection or an outbound call.
|
||||
|
||||
## Rule 3 — a collection that returns `nextCursor` must accept `limit` + `cursor`, and must apply them
|
||||
|
||||
Every list returns `{ data, nextCursor }`. Whether it *pages* is a separate, pinned decision — see `lib/api/contracts/v2/__tests__/list-pagination.test.ts`, which enumerates both sets and fails when a new list is in neither.
|
||||
@@ -95,10 +97,16 @@ Build the query slice from the shared helper, never by hand:
|
||||
|
||||
That gives `limit` (integer, 1..`V2_MAX_PAGE_SIZE`, defaulting to `V2_DEFAULT_PAGE_SIZE` = 50) and an opaque `cursor`. Re-declaring `limit: z.coerce.number()...` inline is how the 500 happened; there is one schema so the family cannot drift again.
|
||||
|
||||
Two cursor schemes exist, both opaque base64-JSON from `response.ts`. Which one you use is decided by what the read can express, not by taste:
|
||||
Three cursor schemes exist. Two are the shared codecs in `response.ts`, both opaque base64-JSON, and which of them you use is decided by what the read can express, not by taste:
|
||||
|
||||
- **Keyset** (`readSortedCursor` in, `encodeSortedCursor` out) — the default. Requires the page to come from one ordered SQL read. The sort is stamped into the cursor and re-checked on replay, so changing `sortBy` mid-pagination is a 400, not a silently skipped page.
|
||||
- **Offset** (`decodeOffsetCursor` / `encodeOffsetCursor`) — only when a keyset is impossible. Two lists qualify: `GET /skills` merges a static in-code registry with DB rows and re-sorts in JS, and `GET /knowledge/{id}/documents` sits on a limit/offset query. An offset cursor **must** be stamped with `offsetCursorScope(...)` covering every param that filters or orders the sequence (not `limit`, which only selects how much of it to return). A bare offset replayed against a re-sorted or re-filtered sequence names a different row, which skips or repeats results — the exact failure the keyset's sort stamp already prevents.
|
||||
- **Keyset** (`readSortedCursor` in, `encodeSortedCursor` out) — the default. Requires the page to come from one ordered SQL read. The sort AND the filters are stamped into the cursor and re-checked on replay, so changing `sortBy` or any filter mid-pagination is a 400, not a silently skipped page.
|
||||
- **Offset** (`decodeOffsetCursor` / `encodeOffsetCursor`) — only when a keyset is impossible. Two lists qualify: `GET /skills` merges a static in-code registry with DB rows and re-sorts in JS, and `GET /knowledge/{id}/documents` sits on a limit/offset query. A bare offset replayed against a re-sorted or re-filtered sequence names a different row, which skips or repeats results.
|
||||
|
||||
Both take the same two stamps: `cursorSortKey(sortBy, sortOrder)` for the ordering, and `cursorFilterScope({ ... })` for every param that filters the sequence. **`limit` is never a stamp** — it selects how much of the sequence to return, not what the sequence is, and binding it strands every cursor the moment a caller changes page size. Params that only shape the response body are out for the same reason.
|
||||
|
||||
The third is **per-domain**: a list whose read predates the shared codecs, or whose page boundary is not expressible as one, mints its own — a bare `encodeCursor({ version })` on `GET /workflows/{id}/versions` and `encodeCursor({ email })` on the workspace member list, the local codecs in `lib/audit-logs/query.ts`, `lib/logs/list-logs.ts`, and `lib/table/rows/cursor.ts`, and a usage-event id passed straight through by `GET /billing/logs`. Those tokens stay opaque and untouched, but a domain-minted cursor on a list a caller can re-filter is wrapped at the surface with `encodeScopedCursor(cursorFilterScope({...}), token)` and unwrapped with `readScopedCursor`, so it carries the same binding as the shared schemes. **A new list picks one of the two shared schemes.** Do not add a fourth.
|
||||
|
||||
Every paged list's binding is declared in `lib/api/contracts/v2/__tests__/list-pagination.test.ts` and checked against what the contract actually accepts, in both directions. A new list, or a new filter on an existing one, fails that test until its binding is declared or the param is explicitly recorded as unable to change the sequence.
|
||||
|
||||
**A keyset's key list must end in a unique column (`id`).** A non-unique trailing key cannot separate tied rows, so the page boundary either repeats or drops them. `lib/api/list-keyset-paging.test.ts` demonstrates the failure.
|
||||
|
||||
@@ -110,6 +118,10 @@ Return `nextCursor: null` on the last page and only then. Never construct a curs
|
||||
|
||||
## Rule 4 — reject what you do not implement
|
||||
|
||||
**Every contract declares a `query`, even when the endpoint takes none** — `query: noInputSchema` (`z.object({}).strict()`), never omission. `parseRequest` validates the query slice only when the contract declares one, so an omitted `query` means "never look at the query string", not "takes no query params". The two were indistinguishable, which is how 69 contracts ended up accepting anything without anyone deciding they should: `GET /workflows/{id}?bogus=1` answered 200 while every list answered 400 for the same shape. `query-declaration.test.ts` sweeps the tree so contract 70 fails at authoring time rather than shipping unvalidated.
|
||||
|
||||
Declaring them is a **deliberate tightening** of endpoints that previously ignored an unknown param. It was weighed and kept: the v2 body slice on those same endpoints was already strict, so the split was arbitrary rather than a promise to callers; a mistyped param that is silently dropped is the bug class this rule exists to prevent; and no first-party caller sends an undeclared v2 query param (the two SDKs send only `includeOutput`/`selectedOutputs`, both declared; the UI makes no v2 calls at all; `requestJson` appends nothing implicitly and no v2 cache buster exists). Third-party callers appending a tracking tag or cache buster do break, which is why `api-reference/getting-started.mdx` documents the behavior rather than leaving it to be discovered from a 400.
|
||||
|
||||
Query and body schemas are **`.strict()`** — and `.strict()` binds the **top level only**. A strict body containing a non-strict nested object still drops unknown keys one level down, which is the headline `filter` bug at a smaller scale: `sort: [{ field, direction, nulls: 'last' }]` answered 200 and ordered by the default. Strictness belongs on the shared nested schema (`sortSpecSchema`'s element, `tableViewConfigSchema`), not restated per body.
|
||||
|
||||
Before tightening a schema that is **also** a response or a stored blob, make the read canonical first. `table_views.config` is schemaless JSONB, so a legacy row carrying a retired key would fail a newly strict response parse and become a 500; `normalizeStoredViewConfig` projects the stored blob onto the declared keys so the tightening is safe in both directions. Zod strips unknown keys by default, so a non-strict schema answers `?limit=1` with 200 and the whole set — the caller believes it bounded the response and it did not. That is a contract lie, and on an uncapped list it is also an unbounded-response risk.
|
||||
@@ -131,7 +143,7 @@ That last one is the standard to aim for. A message that only says `Invalid inpu
|
||||
Order matters because each layer is checked against the one before it.
|
||||
|
||||
1. **Contract** in `lib/api/contracts/v2/<domain>.ts` via `defineRouteContract`. Response schemas are `.parse`d on the way out, so a field the producer does not actually emit becomes a 500 on a successful read — assert only what you can prove.
|
||||
2. **Application use case** owns canonical loading, authorization, business behavior, and audit. The route's `present` receives **only the use-case result**, so anything the presenter needs (e.g. the active `sortBy`/`sortOrder` to stamp a cursor) must be returned by the use case.
|
||||
2. **Application use case** owns canonical loading, authorization, business behavior, and audit. The route's `present` receives the use-case result **and the parsed request**, so a presenter reads request params (the active `sortBy`/`sortOrder` and filters it stamps into a cursor) straight from `query`/`params` rather than making the use case carry an HTTP concern back out.
|
||||
3. **Route** with `defineV2JsonRoute`, declaring `contract`, `auth: v2ApiKeyAuth`, `operation`, `rateLimit`, `errorPolicy`, `mapInput`, `useCase`, `present`. Auth and rate limiting run before parsing.
|
||||
4. **OpenAPI description** in `lib/api/contracts/v2/openapi/<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.
|
||||
|
||||
@@ -152,7 +164,7 @@ Do not add a default for any other code. 400/403/404/409 are not fixed by waitin
|
||||
|
||||
**A failure whose outcome is unknown must not advise a retry.** `ASYNC_ENQUEUE_AMBIGUOUS` is a 503 whose enqueue may have succeeded — it deliberately retains its execution-ID claim. Telling that caller to come back in 5 seconds invites a client with no `X-Run-Id` to start and bill a second run. It passes `omitRetryAfter: true` and returns the run id so the caller reconciles instead. Any future "we don't know if it happened" failure does the same.
|
||||
|
||||
RFC 9110 §10.2.3 gives 503 this field's clearest meaning — "how long the service is expected to be unavailable to the client". Note the requirement level is only `MAY`, on 503 (§15.6.4) and, via RFC 6585 §4, on 429. It is `SHOULD` on exactly one status, 413, and only when the condition is temporary; Sim's 413 is a fixed byte ceiling, so it correctly sends none.
|
||||
RFC 9110 §10.2.3 gives 503 this field's clearest meaning — "how long the service is expected to be unavailable to the client". Note the requirement level is only `MAY`, on 503 (§15.6.4) and, via RFC 6585 §4, on 429. It is `SHOULD` on exactly one status, 413, and only when the condition is temporary; none of Sim's 413s are temporary — they are fixed ceilings, on the request body and on the collections a response must materialize — so it correctly sends none.
|
||||
|
||||
## Deliberate non-adoptions
|
||||
|
||||
@@ -185,7 +197,7 @@ That makes the money path safe against double-execution **for callers that opt i
|
||||
The base64-JSON cursor is **not signed**, and does not need to be. Tampering is bounded by construction, and that is a property to preserve:
|
||||
|
||||
- Every key value is re-validated by its `KeysetKey.bind`, which returns `null` for a wrong-typed or unparseable value and becomes a 400. A forged cursor cannot reach SQL as `NaN` or an `Invalid Date`.
|
||||
- The sort is stamped into the cursor and re-checked (`decodeSortedCursor`), so a cursor from a differently-sorted query is a 400, not a silently skipped page.
|
||||
- The sort and a fingerprint of the filters are stamped into the cursor and re-checked (`decodeSortedCursor`), so a cursor from a differently-sorted or differently-filtered query is a 400, not a silently skipped page. The filters are hashed (SHA-256, via `lib/api/cursor-binding.ts`) rather than embedded, so the token stays short and a caller cannot cheaply construct a filter that collides with another sequence's stamp.
|
||||
- The offset codec rejects anything that is not a non-negative integer.
|
||||
- Authorization is **never** carried in the cursor. Every list re-derives its workspace scope from the authenticated principal, so a cursor lifted from another query — or another tenant — can only move the caller within their own authorized result set.
|
||||
|
||||
@@ -198,9 +210,11 @@ Run this against any new or changed v2 endpoint.
|
||||
- [ ] Success body is exactly `{data}` or `{data, nextCursor}`; failures are exactly `{error:{code,message,details?}}`.
|
||||
- [ ] Route uses a shared builder; no hand-built `NextResponse.json`.
|
||||
- [ ] Query and body schemas are `.strict()`.
|
||||
- [ ] The contract declares a `query` — `noInputSchema` when the endpoint takes no query params, never omission.
|
||||
- [ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer, and that any bound value passed as an argument to a SQL function carries an explicit type.
|
||||
- [ ] `limit` comes from `v2PaginationFields`, not a hand-written `z.coerce.number()`.
|
||||
- [ ] If the response carries `nextCursor`, the query accepts `limit` + `cursor` and the query actually applies them.
|
||||
- [ ] The cursor is bound to every param that filters or orders the sequence, and to none that do not (never `limit`), with the binding declared in `list-pagination.test.ts`.
|
||||
- [ ] Keyset sorts end in a unique `id` key.
|
||||
- [ ] The list is classified in `list-pagination.test.ts`.
|
||||
- [ ] Cross-tenant access answers 404, never 403 — and carries `Cache-Control: private, no-store`, because RFC 9110 §15.5.5 makes 404 heuristically cacheable and an authorization-dependent 404 must never be stored. `v2Error` sets this unconditionally; do not build a v2 response any other way.
|
||||
|
||||
@@ -57,6 +57,27 @@ Use the `migrate-application-operation` skill before creating or migrating a pro
|
||||
|
||||
Every export of a `'use client'` module becomes a *client reference* on the server — server-evaluated code (RSC pages/layouts, `prefetch.ts`, route handlers, block definitions, triggers) can only *render* it as a component or pass it as a prop, never *call* it (doing so throws at runtime, e.g. `tableKeys.list is not a function`; `next build` does not catch it). Keep server-importable query primitives (key factories, fetchers, mappers, constants) in non-`'use client'` modules — see `.claude/rules/sim-queries.md`. Enforced by `scripts/check-client-boundary-imports.ts`.
|
||||
|
||||
## The app/worker runtime boundary
|
||||
|
||||
Server code runs in two runtimes with **different environments**. The app container loads the
|
||||
full env from `SIM_ENV_SECRET_ID` (Secrets Manager). Trigger.dev workers — which execute
|
||||
workflows, so every block handler and every tool call — get their env from the Trigger.dev
|
||||
dashboard, and `trigger.config.ts` syncs only `DB_APP_NAME`. The repo cannot see what the
|
||||
dashboard holds.
|
||||
|
||||
So before replacing a worker's HTTP call to our own API with an in-process call, ask what env
|
||||
that work reads *on the app side*. Anything gated by a `require*Capability` helper is the sharp
|
||||
case: those **throw** when the variable is absent (`requireOAuthClientCapability` →
|
||||
`EnvCapabilityConfigurationError`), and the throw may be caught and reported as something
|
||||
unrelated. OAuth token refresh is the known example — moving it into the worker turns every
|
||||
expired credential into `Failed to refresh access token`, while a still-valid token hides the
|
||||
bug entirely, so it surfaces hours later and only for whoever's token lapsed first.
|
||||
|
||||
An in-process conversion is safe when the same work already runs in that runtime (the agent
|
||||
block has always called `executeProviderRequest` in-process, so router and evaluator joining it
|
||||
is proven), or when the caller and the callee are both the app (a route calling a lib module, an
|
||||
RSC prefetch reading the data layer). It is not safe on reasoning alone.
|
||||
|
||||
## Feature Organization
|
||||
|
||||
Features live under `app/workspace/[workspaceId]/`:
|
||||
|
||||
@@ -143,6 +143,22 @@ const handler = useCallback(() => {
|
||||
}, [data])
|
||||
```
|
||||
|
||||
## Server prefetching
|
||||
|
||||
A server prefetch fills the *same* cache key a client hook fills, so it must be indistinguishable from a client fetch. Five rules:
|
||||
|
||||
1. **Read the data layer, never our own API over HTTP.** A server-to-server call to `/api/...` costs a round trip and a second authentication for data the process can already read. Where the route runs an application use case, call that same use case with a principal from the same auth policy the route declares — not a manager underneath it.
|
||||
2. **Match the wire shape the hook caches.** The hook's data is whatever `requestJson(contract, …)` produced, so the seed must equal it. Two traps: a contract field declared `z.coerce.date()` means the hook holds a `Date` where raw route JSON holds a string; a passthrough response schema (`z.custom`) means the hook caches route JSON *verbatim*, so seeding raw rows leaks `Date`s and server-only fields. When the route projects before responding, share that projection — have the route and the prefetch call one function.
|
||||
3. **Prove the viewer.** Data-layer reads carry no authorization; the route used to provide it. Resolve the viewer (`getWorkspaceHostContextForViewer`, already `cache`d by the layout so it costs nothing) and return early on failure, caching nothing — the client fetch then reaches the route for the real 403. Never widen what a viewer can see.
|
||||
4. **Always `await`.** Only a settled query is dehydrated, so an unawaited prefetch is silently dropped from the payload and the pane waterfalls anyway.
|
||||
5. **Don't repeat what the layout already seeded.** `getQueryClient()` builds a new client per server call, so a page re-seeding a layout key is a genuine second read — and `HydrationBoundary` defers an already-seen query to an effect, which SSR never runs, so it never reaches the server render either.
|
||||
|
||||
Reuse the hook's exported `staleTime` constant and its key factory; `dehydrate` carries neither options nor `staleTime`, and freshness is per-observer.
|
||||
|
||||
Seed with `setQueryData` only when the prefetch must be able to *decline* to create an entry (an empty list that has to fall through to a route's creation path). `prefetchQuery` and `ensureQueryData` always create one.
|
||||
|
||||
Keep prefetch imports light. A page prefetch's imports land in that route's server graph, so pulling a barrel to reach one function can drag thousands of modules behind it — `bun run check:tool-registry-boundary` gates this per page.
|
||||
|
||||
## Boundary Types
|
||||
|
||||
- Hooks import named type aliases from `@/lib/api/contracts/**` (e.g., `import { listEntitiesContract, type EntityList } from '@/lib/api/contracts/entities'`). Never write `z.input<...>` / `z.output<...>` in hooks, and never `import { z } from 'zod'` in client code.
|
||||
|
||||
@@ -73,6 +73,43 @@ Classify each as `migrate`, `defer`, or `non-goal`. Do not migrate adjacent oper
|
||||
|
||||
Preserve behavior unless the task explicitly changes it. Stop and report a decision when surfaces currently disagree on security or compatibility behavior; do not silently choose one.
|
||||
|
||||
## Freeze observable behavior before editing
|
||||
|
||||
Treat the legacy route or tool as an ordered program, not merely a bag of business logic. Before moving code, write a compact baseline for every in-scope entry point and add focused characterization tests for behavior not already pinned down.
|
||||
|
||||
Capture all of these when they apply:
|
||||
|
||||
- Accepted inputs, including trimming, blank omission, duplicate query keys, aliases, defaults, and bounds.
|
||||
- Authentication and authorization order, minimum roles, resource membership, concealment, and exact error/status mapping.
|
||||
- Exact success bodies, optional fields, status codes, redirects, cookies, headers, and binary or stream behavior.
|
||||
- Mutation ordering, transaction boundaries, idempotency, no-ops, and observable state after each possible partial failure.
|
||||
- Audit, notification, analytics, and billing timing plus exact semantic dimensions and attribution.
|
||||
- Browser or protocol state ownership, concurrency isolation, expiry, callback ordering, and cleanup behavior.
|
||||
- Every value newly crossing into HTML, JavaScript, SQL, URLs, logs, provider payloads, or another encoding context.
|
||||
|
||||
Compare the old statement order with the proposed application lifecycle explicitly:
|
||||
|
||||
```text
|
||||
legacy parse/normalize
|
||||
-> legacy authorization checks
|
||||
-> branch-specific canonical lookup
|
||||
-> mutation(s)
|
||||
-> per-step side effects
|
||||
-> response or redirect catch
|
||||
```
|
||||
|
||||
Moving those steps under a wrapper may change behavior even when each individual call is reused. In particular:
|
||||
|
||||
- `projectAudit` and `afterSuccess` run only after `execute` returns. They cannot describe earlier committed mutations when a later step throws. Make the compound mutation atomic or define explicit partial-result/failure projection semantics before migrating it.
|
||||
- Operation metadata is executable policy. Adding a resource role to a workspace-only legacy read is an authorization change, not an architectural cleanup.
|
||||
- A shared error policy does not automatically preserve route-local concealment, subclass ordering, browser redirects, or branch-specific messages.
|
||||
- A shared contract does not automatically preserve manual `URLSearchParams` normalization or exact legacy response unions.
|
||||
- A shared use case may own domain behavior while separate surface presenters still preserve different wire shapes.
|
||||
- Per-flow identity is insufficient when another part of the flow remains in browser-global state such as one cookie.
|
||||
- Passing a newly supported parameter through old rendering code creates a new security boundary even when the renderer itself is unchanged.
|
||||
|
||||
Fail fast if the baseline cannot be established from code, tests, or an explicit product decision. Do not infer that behavior is unimportant because it was previously implicit.
|
||||
|
||||
## Keep the layers distinct
|
||||
|
||||
Use these responsibilities:
|
||||
@@ -265,6 +302,10 @@ Add focused tests for every migrated surface and principal kind allowed by the o
|
||||
- Public API: personal and workspace keys, rate and rollout behavior, concealment, exact external envelope, and rate headers.
|
||||
- Copilot or tools: trusted context, exact registered operation membership, rejected forged scope, aliases and resume paths, permission re-check, safe errors, and unchanged tool result shapes.
|
||||
- Side effects: audit derives from authoritative results; shared notifications follow audit; neither occurs for rejection or no-op.
|
||||
- Compatibility characterization: legacy normalization, exact response/redirect/cookie behavior, concealment, error subclass precedence, and branch-specific output.
|
||||
- Failure sequencing: inject a failure after each independently committing step and assert persisted state plus audit, analytics, and notification effects.
|
||||
- Concurrency: overlap stateful browser or provider flows and prove each callback consumes only its own state and return destination.
|
||||
- Rendering boundaries: exercise hostile values for every newly connected input that reaches HTML, inline JavaScript, URLs, logs, or provider requests.
|
||||
|
||||
Run at minimum:
|
||||
|
||||
|
||||
@@ -94,6 +94,16 @@ improvement(scope): description for enhancements
|
||||
chore(scope): description for maintenance
|
||||
```
|
||||
|
||||
## What to Omit
|
||||
|
||||
The repo is public. Keep the title and description to the code change and its reasoning — never:
|
||||
|
||||
- Customer, company, or user names; workspace/user/org IDs; email addresses
|
||||
- Prod or staging operational data: log lines, DB rows, metrics, timestamps, incident details, canary/alert output
|
||||
- Infrastructure specifics: hostnames, ARNs, internal URLs, env var values, secret names
|
||||
|
||||
Describe the bug by its mechanism, not by how you found it. "Expired OAuth credentials fail to refresh in the worker" — not "the Sheets canary failed at 16:31Z for workspace abc-123".
|
||||
|
||||
## PR Description Format
|
||||
|
||||
Use this exact template in the user's voice (concise, bullet points):
|
||||
|
||||
@@ -63,6 +63,10 @@ If it fails, do not add the entry to an allowlist — there isn't one. Find the
|
||||
|
||||
Run it with `--verbose` to print per-route module counts, which is also the quickest way to see whether a change moved the graph.
|
||||
|
||||
The same command also ratchets those counts against `check-tool-registry-boundary.baseline.json`. `--check` (what CI runs) fails when an entry exceeds its baseline by more than `max(25 modules, 2%)`, naming the import chain responsible. This catches bloat the registry rule misses — a prefetch importing `listTables` cost the Tables page 444 modules without ever touching `@/tools/registry`.
|
||||
|
||||
Re-record with `--update-baseline` and commit the JSON when growth is deliberate. A *shrink* passes but is reported — re-record then too, or the win is silently spendable again.
|
||||
|
||||
## How to verify an edge actually got cut
|
||||
|
||||
Do not eyeball imports — the registry is reached through several redundant paths, so cutting one buys nothing while another survives. Walk the graph:
|
||||
|
||||
@@ -13,7 +13,7 @@ Nothing else at the top level. No `success: true`, no bare `{ "error": "string"
|
||||
That promise is worth stating as a rule because it has been broken five separate ways, each time by a route or a builder taking a shortcut that looked local:
|
||||
|
||||
- `GET /workflows?limit=1.5` returned **500**. The contract was copied from a sibling and lost its `.int()`, so a fractional limit passed validation and reached Postgres as `LIMIT 2.5`.
|
||||
- A malformed JSON body returned **`{"error":"Request body must be valid JSON"}`** — a bare string. The envelope was a per-route opt-in that only 8 of 77 routes remembered.
|
||||
- A malformed JSON body returned **`{"error":"Request body must be valid JSON"}`** — a bare string. The envelope was a per-route opt-in that only 8 routes remembered.
|
||||
- `GET /api/v2/nonexistent` returned a **full HTML 404 document**, because no route file matched and the request fell through to the app's global not-found page.
|
||||
- Four collections returned `nextCursor` while **silently discarding** any `limit` the caller sent, because Zod strips unknown keys unless the schema is `.strict()`.
|
||||
- Handing back a `nextCursor` from any timestamp-sorted list and passing it straight in returned **500**. The value was validated and bound — but bound with no SQL type, into `date_trunc`, which is overloaded, so Postgres could resolve no overload. Validation was never the missing half; the type was.
|
||||
@@ -46,10 +46,10 @@ A route built with `defineV2JsonRoute` gets this for free: its `present` returns
|
||||
| 200 / 201 | — | Success. 201 only for a created resource. |
|
||||
| 400 | `BAD_REQUEST` | Contract validation. Carries field-level `details` from `serializeZodIssues`. |
|
||||
| 401 | `UNAUTHORIZED` | No/!valid API key. **Runs before the rollout gate.** |
|
||||
| 403 | `FORBIDDEN` | Authenticated, same tenant, insufficient rights. Carries a machine-readable `details.code` from the closed set in `lib/core/application/forbidden.ts` (e.g. `INSUFFICIENT_WORKSPACE_ROLE`, `PERSONAL_API_KEYS_DISABLED`). |
|
||||
| 403 | `FORBIDDEN` | Authenticated, same tenant, insufficient rights. Where the cause is one a caller can act on it is named in `details.code`, from the closed set in `lib/core/application/forbidden.ts` (e.g. `INSUFFICIENT_WORKSPACE_ROLE`, `PERSONAL_API_KEYS_DISABLED`). A few domain refusals still reach the wire without one. |
|
||||
| 404 | `NOT_FOUND` | Not found, **and** cross-tenant concealment, **and** the rollout gate, **and** an unknown path. |
|
||||
| 409 | `CONFLICT` | Uniqueness/state conflict, human-readable message. |
|
||||
| 413 | `PAYLOAD_TOO_LARGE` | Body over the route's `maxBodyBytes`. |
|
||||
| 413 | `PAYLOAD_TOO_LARGE` | Body over the route's `maxBodyBytes` — **and** a collection the response must materialize that is over *its* ceiling. Fourteen bodyless `GET`/`DELETE` operations publish it for the folder-tree cap (`FolderCollectionLimitExceededError`) or the rendered-artifact cap. |
|
||||
| 429 | `RATE_LIMITED` | With `Retry-After` and `X-RateLimit-*`. |
|
||||
| 500 | `INTERNAL_ERROR` | Genuine server fault only. Message is always generic. |
|
||||
|
||||
@@ -68,7 +68,7 @@ And this class survives a green test suite — `keysetAfter` returned well-forme
|
||||
- An operation whose `minimumRole` is `write` or `admin` can always 403 — a member with a lower role hits it. Document 403.
|
||||
- An operation whose `minimumRole` is `read` cannot 403 *that* way, because `read` is the floor of the `read < write < admin` ordering and anyone without access is concealed as 404 instead. It can still 403 through `PersonalApiKeysDisabledError` (a personal API key against a workspace whose organization disabled them) or `WorkspaceApiKeyAuthorizationError` (`workspaceApiKey: 'deny'`), and every v2 operation is reachable by a personal API key. **So in practice every workspace-scoped v2 operation documents 403**, and the reads that omitted it were wrong, not principled.
|
||||
|
||||
**Every 403 names its cause in `error.details.code`.** One status covers four different remedies — raise a member's role, issue a personal key instead of a workspace-scoped one, re-point a workspace key, buy an enterprise plan — and prose is not branchable, so a client that must tell them apart was string-matching messages, which turns every reword into a silent break.
|
||||
**A 403 a caller can act on names its cause in `error.details.code` — but not every 403 does yet.** One status covers several different remedies — raise a member's role, issue a personal key instead of a workspace-scoped one, re-point a workspace key, buy an enterprise plan, delete a resource to get under a quota — and prose is not branchable, so a client that must tell them apart was string-matching messages, which turns every reword into a silent break. A handful of domain refusals still throw a bare `OrchestrationError('forbidden', …)` and reach the wire with no code, so **write client code that treats `details.code` as optional**, and read `openapi/shared.ts`'s `FORBIDDEN_DESCRIPTION` for the current position rather than assuming the sweep is finished. For code you are *writing*, the rule below is unconditional.
|
||||
|
||||
The vocabulary is a closed set, `FORBIDDEN_DETAIL_CODES` in `lib/core/application/forbidden.ts`, with a `Record` of descriptions beside it that the generated OpenAPI 403 description is built from. Adding a member fails to compile until it is documented, so a code cannot reach the wire unpublished. Do not invent a code at a route: throw `ForbiddenOperationError(code, message)` from the domain and let `v2CaughtOrchestrationError` — the function every v2 error policy falls through to — attach it. `InsufficientWorkspacePermissionsError`, `PersonalApiKeysDisabledError`, `WorkspaceApiKeyAuthorizationError`, and `PrincipalKindAuthorizationError` already carry theirs.
|
||||
|
||||
@@ -78,6 +78,8 @@ Use the shared sets in `contracts/v2/openapi/shared.ts` — `RESOURCE_ERRORS`, `
|
||||
|
||||
**HEAD is answered by the `GET` handler, not rejected.** Next aliases a missing `HEAD` export onto `GET` and drops the body when sending, so a route's `GET` legitimately runs with `request.method === 'HEAD'`. The builders' method guard accepts that pairing via `methodMatchesContract`; any other mismatch stays a hard error. Never hand-write a `HEAD` export to "fix" this.
|
||||
|
||||
**A `GET` with side effects must declare `headSafe: false`.** The aliasing above is only sound because RFC 9110 §9.2.1 defines `HEAD` as safe. A `GET` that writes a row or opens an outbound connection is not, and an uptime monitor or link checker walking the documented URL list would drive those effects on every probe — `GET /files/{fileId}` records a `FILE_DOWNLOADED` audit event, so a `HEAD` used to fabricate a download that never happened. Both the JSON and binary builders take `headSafe`: a route that sets it `false` still authenticates and rate-limits a `HEAD`, then answers `v2HeadNoEffect()` — a bodiless 200 — before parsing or executing. Nothing observable is lost, because `HEAD` carries no body either way. Audit this whenever a read acquires an audit projection or an outbound call.
|
||||
|
||||
## Rule 3 — a collection that returns `nextCursor` must accept `limit` + `cursor`, and must apply them
|
||||
|
||||
Every list returns `{ data, nextCursor }`. Whether it *pages* is a separate, pinned decision — see `lib/api/contracts/v2/__tests__/list-pagination.test.ts`, which enumerates both sets and fails when a new list is in neither.
|
||||
@@ -90,10 +92,16 @@ Build the query slice from the shared helper, never by hand:
|
||||
|
||||
That gives `limit` (integer, 1..`V2_MAX_PAGE_SIZE`, defaulting to `V2_DEFAULT_PAGE_SIZE` = 50) and an opaque `cursor`. Re-declaring `limit: z.coerce.number()...` inline is how the 500 happened; there is one schema so the family cannot drift again.
|
||||
|
||||
Two cursor schemes exist, both opaque base64-JSON from `response.ts`. Which one you use is decided by what the read can express, not by taste:
|
||||
Three cursor schemes exist. Two are the shared codecs in `response.ts`, both opaque base64-JSON, and which of them you use is decided by what the read can express, not by taste:
|
||||
|
||||
- **Keyset** (`readSortedCursor` in, `encodeSortedCursor` out) — the default. Requires the page to come from one ordered SQL read. The sort is stamped into the cursor and re-checked on replay, so changing `sortBy` mid-pagination is a 400, not a silently skipped page.
|
||||
- **Offset** (`decodeOffsetCursor` / `encodeOffsetCursor`) — only when a keyset is impossible. Two lists qualify: `GET /skills` merges a static in-code registry with DB rows and re-sorts in JS, and `GET /knowledge/{id}/documents` sits on a limit/offset query. An offset cursor **must** be stamped with `offsetCursorScope(...)` covering every param that filters or orders the sequence (not `limit`, which only selects how much of it to return). A bare offset replayed against a re-sorted or re-filtered sequence names a different row, which skips or repeats results — the exact failure the keyset's sort stamp already prevents.
|
||||
- **Keyset** (`readSortedCursor` in, `encodeSortedCursor` out) — the default. Requires the page to come from one ordered SQL read. The sort AND the filters are stamped into the cursor and re-checked on replay, so changing `sortBy` or any filter mid-pagination is a 400, not a silently skipped page.
|
||||
- **Offset** (`decodeOffsetCursor` / `encodeOffsetCursor`) — only when a keyset is impossible. Two lists qualify: `GET /skills` merges a static in-code registry with DB rows and re-sorts in JS, and `GET /knowledge/{id}/documents` sits on a limit/offset query. A bare offset replayed against a re-sorted or re-filtered sequence names a different row, which skips or repeats results.
|
||||
|
||||
Both take the same two stamps: `cursorSortKey(sortBy, sortOrder)` for the ordering, and `cursorFilterScope({ ... })` for every param that filters the sequence. **`limit` is never a stamp** — it selects how much of the sequence to return, not what the sequence is, and binding it strands every cursor the moment a caller changes page size. Params that only shape the response body are out for the same reason.
|
||||
|
||||
The third is **per-domain**: a list whose read predates the shared codecs, or whose page boundary is not expressible as one, mints its own — a bare `encodeCursor({ version })` on `GET /workflows/{id}/versions` and `encodeCursor({ email })` on the workspace member list, the local codecs in `lib/audit-logs/query.ts`, `lib/logs/list-logs.ts`, and `lib/table/rows/cursor.ts`, and a usage-event id passed straight through by `GET /billing/logs`. Those tokens stay opaque and untouched, but a domain-minted cursor on a list a caller can re-filter is wrapped at the surface with `encodeScopedCursor(cursorFilterScope({...}), token)` and unwrapped with `readScopedCursor`, so it carries the same binding as the shared schemes. **A new list picks one of the two shared schemes.** Do not add a fourth.
|
||||
|
||||
Every paged list's binding is declared in `lib/api/contracts/v2/__tests__/list-pagination.test.ts` and checked against what the contract actually accepts, in both directions. A new list, or a new filter on an existing one, fails that test until its binding is declared or the param is explicitly recorded as unable to change the sequence.
|
||||
|
||||
**A keyset's key list must end in a unique column (`id`).** A non-unique trailing key cannot separate tied rows, so the page boundary either repeats or drops them. `lib/api/list-keyset-paging.test.ts` demonstrates the failure.
|
||||
|
||||
@@ -105,6 +113,10 @@ Return `nextCursor: null` on the last page and only then. Never construct a curs
|
||||
|
||||
## Rule 4 — reject what you do not implement
|
||||
|
||||
**Every contract declares a `query`, even when the endpoint takes none** — `query: noInputSchema` (`z.object({}).strict()`), never omission. `parseRequest` validates the query slice only when the contract declares one, so an omitted `query` means "never look at the query string", not "takes no query params". The two were indistinguishable, which is how 69 contracts ended up accepting anything without anyone deciding they should: `GET /workflows/{id}?bogus=1` answered 200 while every list answered 400 for the same shape. `query-declaration.test.ts` sweeps the tree so contract 70 fails at authoring time rather than shipping unvalidated.
|
||||
|
||||
Declaring them is a **deliberate tightening** of endpoints that previously ignored an unknown param. It was weighed and kept: the v2 body slice on those same endpoints was already strict, so the split was arbitrary rather than a promise to callers; a mistyped param that is silently dropped is the bug class this rule exists to prevent; and no first-party caller sends an undeclared v2 query param (the two SDKs send only `includeOutput`/`selectedOutputs`, both declared; the UI makes no v2 calls at all; `requestJson` appends nothing implicitly and no v2 cache buster exists). Third-party callers appending a tracking tag or cache buster do break, which is why `api-reference/getting-started.mdx` documents the behavior rather than leaving it to be discovered from a 400.
|
||||
|
||||
Query and body schemas are **`.strict()`** — and `.strict()` binds the **top level only**. A strict body containing a non-strict nested object still drops unknown keys one level down, which is the headline `filter` bug at a smaller scale: `sort: [{ field, direction, nulls: 'last' }]` answered 200 and ordered by the default. Strictness belongs on the shared nested schema (`sortSpecSchema`'s element, `tableViewConfigSchema`), not restated per body.
|
||||
|
||||
Before tightening a schema that is **also** a response or a stored blob, make the read canonical first. `table_views.config` is schemaless JSONB, so a legacy row carrying a retired key would fail a newly strict response parse and become a 500; `normalizeStoredViewConfig` projects the stored blob onto the declared keys so the tightening is safe in both directions. Zod strips unknown keys by default, so a non-strict schema answers `?limit=1` with 200 and the whole set — the caller believes it bounded the response and it did not. That is a contract lie, and on an uncapped list it is also an unbounded-response risk.
|
||||
@@ -126,7 +138,7 @@ That last one is the standard to aim for. A message that only says `Invalid inpu
|
||||
Order matters because each layer is checked against the one before it.
|
||||
|
||||
1. **Contract** in `lib/api/contracts/v2/<domain>.ts` via `defineRouteContract`. Response schemas are `.parse`d on the way out, so a field the producer does not actually emit becomes a 500 on a successful read — assert only what you can prove.
|
||||
2. **Application use case** owns canonical loading, authorization, business behavior, and audit. The route's `present` receives **only the use-case result**, so anything the presenter needs (e.g. the active `sortBy`/`sortOrder` to stamp a cursor) must be returned by the use case.
|
||||
2. **Application use case** owns canonical loading, authorization, business behavior, and audit. The route's `present` receives the use-case result **and the parsed request**, so a presenter reads request params (the active `sortBy`/`sortOrder` and filters it stamps into a cursor) straight from `query`/`params` rather than making the use case carry an HTTP concern back out.
|
||||
3. **Route** with `defineV2JsonRoute`, declaring `contract`, `auth: v2ApiKeyAuth`, `operation`, `rateLimit`, `errorPolicy`, `mapInput`, `useCase`, `present`. Auth and rate limiting run before parsing.
|
||||
4. **OpenAPI description** in `lib/api/contracts/v2/openapi/<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.
|
||||
|
||||
@@ -147,7 +159,7 @@ Do not add a default for any other code. 400/403/404/409 are not fixed by waitin
|
||||
|
||||
**A failure whose outcome is unknown must not advise a retry.** `ASYNC_ENQUEUE_AMBIGUOUS` is a 503 whose enqueue may have succeeded — it deliberately retains its execution-ID claim. Telling that caller to come back in 5 seconds invites a client with no `X-Run-Id` to start and bill a second run. It passes `omitRetryAfter: true` and returns the run id so the caller reconciles instead. Any future "we don't know if it happened" failure does the same.
|
||||
|
||||
RFC 9110 §10.2.3 gives 503 this field's clearest meaning — "how long the service is expected to be unavailable to the client". Note the requirement level is only `MAY`, on 503 (§15.6.4) and, via RFC 6585 §4, on 429. It is `SHOULD` on exactly one status, 413, and only when the condition is temporary; Sim's 413 is a fixed byte ceiling, so it correctly sends none.
|
||||
RFC 9110 §10.2.3 gives 503 this field's clearest meaning — "how long the service is expected to be unavailable to the client". Note the requirement level is only `MAY`, on 503 (§15.6.4) and, via RFC 6585 §4, on 429. It is `SHOULD` on exactly one status, 413, and only when the condition is temporary; none of Sim's 413s are temporary — they are fixed ceilings, on the request body and on the collections a response must materialize — so it correctly sends none.
|
||||
|
||||
## Deliberate non-adoptions
|
||||
|
||||
@@ -180,7 +192,7 @@ That makes the money path safe against double-execution **for callers that opt i
|
||||
The base64-JSON cursor is **not signed**, and does not need to be. Tampering is bounded by construction, and that is a property to preserve:
|
||||
|
||||
- Every key value is re-validated by its `KeysetKey.bind`, which returns `null` for a wrong-typed or unparseable value and becomes a 400. A forged cursor cannot reach SQL as `NaN` or an `Invalid Date`.
|
||||
- The sort is stamped into the cursor and re-checked (`decodeSortedCursor`), so a cursor from a differently-sorted query is a 400, not a silently skipped page.
|
||||
- The sort and a fingerprint of the filters are stamped into the cursor and re-checked (`decodeSortedCursor`), so a cursor from a differently-sorted or differently-filtered query is a 400, not a silently skipped page. The filters are hashed (SHA-256, via `lib/api/cursor-binding.ts`) rather than embedded, so the token stays short and a caller cannot cheaply construct a filter that collides with another sequence's stamp.
|
||||
- The offset codec rejects anything that is not a non-negative integer.
|
||||
- Authorization is **never** carried in the cursor. Every list re-derives its workspace scope from the authenticated principal, so a cursor lifted from another query — or another tenant — can only move the caller within their own authorized result set.
|
||||
|
||||
@@ -193,9 +205,11 @@ Run this against any new or changed v2 endpoint.
|
||||
- [ ] Success body is exactly `{data}` or `{data, nextCursor}`; failures are exactly `{error:{code,message,details?}}`.
|
||||
- [ ] Route uses a shared builder; no hand-built `NextResponse.json`.
|
||||
- [ ] Query and body schemas are `.strict()`.
|
||||
- [ ] The contract declares a `query` — `noInputSchema` when the endpoint takes no query params, never omission.
|
||||
- [ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer, and that any bound value passed as an argument to a SQL function carries an explicit type.
|
||||
- [ ] `limit` comes from `v2PaginationFields`, not a hand-written `z.coerce.number()`.
|
||||
- [ ] If the response carries `nextCursor`, the query accepts `limit` + `cursor` and the query actually applies them.
|
||||
- [ ] The cursor is bound to every param that filters or orders the sequence, and to none that do not (never `limit`), with the binding declared in `list-pagination.test.ts`.
|
||||
- [ ] Keyset sorts end in a unique `id` key.
|
||||
- [ ] The list is classified in `list-pagination.test.ts`.
|
||||
- [ ] Cross-tenant access answers 404, never 403 — and carries `Cache-Control: private, no-store`, because RFC 9110 §15.5.5 makes 404 heuristically cacheable and an authorization-dependent 404 must never be stored. `v2Error` sets this unconditionally; do not build a v2 response any other way.
|
||||
|
||||
@@ -21,6 +21,15 @@ Dockerfile* text eol=lf
|
||||
.gitignore text eol=lf
|
||||
.gitattributes text eol=lf
|
||||
|
||||
# Source files always diff as text. Git otherwise classifies a whole file as
|
||||
# binary the moment it contains a NUL byte, hiding every line of it from review.
|
||||
*.ts diff
|
||||
*.tsx diff
|
||||
*.js diff
|
||||
*.jsx diff
|
||||
*.json diff
|
||||
*.md diff
|
||||
|
||||
# Denote all files that are truly binary and should not be modified
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
|
||||
+41
-22
@@ -715,14 +715,13 @@ jobs:
|
||||
secrets: inherit
|
||||
|
||||
# Per-env desktop prereleases: a dev/staging push that touches shell code
|
||||
# publishes an environment-tagged GitHub prerelease (vX.Y.Z-dev.N from dev,
|
||||
# vX.Y.Z-staging.N from staging). Each environment's /api/desktop/update feed
|
||||
# offers only its stream, so dev-pointed shells pick up dev builds,
|
||||
# staging-pointed shells staging builds, and prod-pointed shells stable
|
||||
# releases — independently. Unlike stable releases, prereleases build even
|
||||
# publishes an environment-tagged GitHub prerelease to the public,
|
||||
# release-only simstudioai/sim-desktop-releases repository. Keeping these
|
||||
# builds out of this source repository prevents its followers from receiving
|
||||
# every internal shell release. Each environment's /api/desktop/update feed
|
||||
# still offers only its own stream. Unlike stable releases, prereleases build
|
||||
# before the Apple signing secrets exist — unsigned, so the update pipeline
|
||||
# is testable end to end; installed shells detect the missing Developer ID
|
||||
# and offer a manual download instead of a Squirrel install.
|
||||
# remains testable end to end with a manual download.
|
||||
create-desktop-prerelease:
|
||||
name: Create Desktop Prerelease
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -732,7 +731,7 @@ jobs:
|
||||
# cancelled") so a probe failure can't produce a release with no build.
|
||||
if: ${{ !cancelled() && needs.detect-desktop-changes.outputs.changed == 'true' && needs.check-desktop-signing.result == 'success' }}
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
@@ -742,10 +741,16 @@ jobs:
|
||||
- name: Compute prerelease version and create draft release
|
||||
id: version
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
DESKTOP_RELEASE_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PRERELEASE_REPOSITORY: simstudioai/sim-desktop-releases
|
||||
SOURCE_REPOSITORY: ${{ github.repository }}
|
||||
SIGNED: ${{ needs.check-desktop-signing.outputs.configured }}
|
||||
run: |
|
||||
if [ -z "$DESKTOP_RELEASE_TOKEN" ]; then
|
||||
echo "::error::DESKTOP_RELEASE_TOKEN is required to publish desktop prereleases."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=dev; APP_NAME="Sim Dev"; else CHANNEL=staging; APP_NAME="Sim Staging"; fi
|
||||
# Prerelease core = next patch after the latest stable release, so
|
||||
# channel builds always outrank the stable they are built on top of
|
||||
@@ -755,7 +760,7 @@ jobs:
|
||||
# Fail loudly if the query itself fails: silently falling back to
|
||||
# v0.0.0 would publish a channel build that sorts below the shipped
|
||||
# stable, and installed shells would never see it as an update.
|
||||
if ! LATEST="$(gh release list --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName')"; then
|
||||
if ! LATEST="$(gh release list --repo "$SOURCE_REPOSITORY" --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName')"; then
|
||||
echo "::error::Could not query the latest stable release."
|
||||
exit 1
|
||||
fi
|
||||
@@ -775,12 +780,14 @@ jobs:
|
||||
fi
|
||||
# Draft until the build uploads its artifacts: drafts are invisible
|
||||
# to the update feed, so a failed or in-flight build can never take
|
||||
# the channel down with an assetless release. Publishing later also
|
||||
# defers tag creation, so failed builds strand no tags.
|
||||
gh release create "$TAG" \
|
||||
# the channel down with an assetless release. The release-only repo
|
||||
# has no source commit for this SHA, so its tag intentionally targets
|
||||
# that repository's main branch; the notes retain the source SHA.
|
||||
GH_TOKEN="$DESKTOP_RELEASE_TOKEN" gh release create "$TAG" \
|
||||
--repo "$PRERELEASE_REPOSITORY" \
|
||||
--draft \
|
||||
--prerelease \
|
||||
--target "$GITHUB_SHA" \
|
||||
--target main \
|
||||
--title "$TAG" \
|
||||
--notes "$NOTES"
|
||||
echo "version=$TAG" >> "$GITHUB_OUTPUT"
|
||||
@@ -789,6 +796,9 @@ jobs:
|
||||
desktop-prerelease:
|
||||
name: Desktop Prerelease Build
|
||||
needs: [create-desktop-prerelease, check-desktop-signing]
|
||||
# The reusable workflow declares contents: write for its stable-release
|
||||
# path. GitHub cannot elevate a caller's token, even though this prerelease
|
||||
# path uses the dedicated cross-repository token for its actual upload.
|
||||
permissions:
|
||||
contents: write
|
||||
uses: ./.github/workflows/desktop-release.yml
|
||||
@@ -807,14 +817,19 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
needs: [create-desktop-prerelease, desktop-prerelease]
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }}
|
||||
GH_REPO: simstudioai/sim-desktop-releases
|
||||
TAG: ${{ needs.create-desktop-prerelease.outputs.version }}
|
||||
steps:
|
||||
- name: Publish the draft release
|
||||
run: gh release edit "$TAG" --draft=false
|
||||
run: |
|
||||
if [ -z "$GH_TOKEN" ]; then
|
||||
echo "::error::DESKTOP_RELEASE_TOKEN is required to publish desktop prereleases."
|
||||
exit 1
|
||||
fi
|
||||
gh release edit "$TAG" --draft=false
|
||||
|
||||
# Keep the release list tidy: per channel, retain the newest 5 prereleases
|
||||
# and delete the rest (with their tags, so dev force-resets don't strand
|
||||
@@ -826,13 +841,17 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
needs: [publish-desktop-prerelease]
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }}
|
||||
GH_REPO: simstudioai/sim-desktop-releases
|
||||
steps:
|
||||
- name: Delete stale prereleases
|
||||
run: |
|
||||
if [ -z "$GH_TOKEN" ]; then
|
||||
echo "::error::DESKTOP_RELEASE_TOKEN is required to prune desktop prereleases."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNELS='(dev|alpha)'; else CHANNELS='(staging|beta)'; fi
|
||||
gh release list --limit 100 --json tagName,isPrerelease,isDraft,createdAt \
|
||||
--jq "[.[] | select(.isPrerelease and (.isDraft | not) and (.tagName | test(\"-${CHANNELS}\\\\.\")))] | sort_by(.createdAt) | reverse | .[5:] | .[].tagName" |
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
name: Desktop Release (macOS)
|
||||
|
||||
# Builds, signs, notarizes, and uploads the desktop app to an existing GitHub
|
||||
# release. Ordering is load-bearing: scripts/create-single-release.ts skips
|
||||
# creation when the tag already exists, so this workflow must never create the
|
||||
# release itself — it only uploads assets after create-release ran (wired via
|
||||
# workflow_call from ci.yml with needs: [create-release]).
|
||||
# release. Stable releases live in this source repository; dev and staging
|
||||
# releases live in simstudioai/sim-desktop-releases. Ordering is load-bearing:
|
||||
# scripts/create-single-release.ts skips creation when the stable tag already
|
||||
# exists, so this workflow must never create a release itself.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -54,6 +54,46 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
# Prerelease versions carry their environment in the tag: -dev.N is a
|
||||
# dev build, -staging.N a staging build. Legacy -alpha/-beta tags remain
|
||||
# accepted while already-published builds age out. The channel decides the app's
|
||||
# identity (name/bundle id — a separate app per environment, installable
|
||||
# side by side) and the default origin baked into the bundle, which in
|
||||
# turn selects the update feed the installed app polls.
|
||||
- name: Resolve channel identity
|
||||
id: channel
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
case "$VERSION" in
|
||||
*-dev.*|*-alpha.*)
|
||||
NAME='Sim Dev'; APP_ID=ai.sim.desktop.dev; ORIGIN=https://www.dev.sim.ai; RELEASE_REPOSITORY=simstudioai/sim-desktop-releases; TOKEN_KIND=prerelease ;;
|
||||
*-staging.*|*-beta.*)
|
||||
NAME='Sim Staging'; APP_ID=ai.sim.desktop.staging; ORIGIN=https://www.staging.sim.ai; RELEASE_REPOSITORY=simstudioai/sim-desktop-releases; TOKEN_KIND=prerelease ;;
|
||||
*)
|
||||
NAME='Sim'; APP_ID=ai.sim.desktop; ORIGIN=''; RELEASE_REPOSITORY="$GITHUB_REPOSITORY"; TOKEN_KIND=stable ;;
|
||||
esac
|
||||
{
|
||||
echo "name=$NAME"
|
||||
echo "app_id=$APP_ID"
|
||||
echo "origin=$ORIGIN"
|
||||
echo "release_repository=$RELEASE_REPOSITORY"
|
||||
echo "token_kind=$TOKEN_KIND"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "Building $NAME ($APP_ID) for $RELEASE_REPOSITORY; default origin: ${ORIGIN:-production}"
|
||||
|
||||
- name: Validate release authentication
|
||||
if: ${{ inputs.publish }}
|
||||
env:
|
||||
DESKTOP_RELEASE_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }}
|
||||
RELEASE_REPOSITORY: ${{ steps.channel.outputs.release_repository }}
|
||||
TOKEN_KIND: ${{ steps.channel.outputs.token_kind }}
|
||||
run: |
|
||||
if [ "$TOKEN_KIND" = prerelease ] && [ -z "$DESKTOP_RELEASE_TOKEN" ]; then
|
||||
echo "::error::DESKTOP_RELEASE_TOKEN is required to publish prereleases to $RELEASE_REPOSITORY."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
@@ -91,32 +131,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Prerelease versions carry their environment in the tag: -dev.N is a
|
||||
# dev build, -staging.N a staging build. Legacy -alpha/-beta tags remain
|
||||
# accepted while already-published builds age out. The channel decides the app's
|
||||
# identity (name/bundle id — a separate app per environment, installable
|
||||
# side by side) and the default origin baked into the bundle, which in
|
||||
# turn selects the update feed the installed app polls.
|
||||
- name: Resolve channel identity
|
||||
id: channel
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
case "$VERSION" in
|
||||
*-dev.*|*-alpha.*)
|
||||
NAME='Sim Dev'; APP_ID=ai.sim.desktop.dev; ORIGIN=https://www.dev.sim.ai ;;
|
||||
*-staging.*|*-beta.*)
|
||||
NAME='Sim Staging'; APP_ID=ai.sim.desktop.staging; ORIGIN=https://www.staging.sim.ai ;;
|
||||
*)
|
||||
NAME='Sim'; APP_ID=ai.sim.desktop; ORIGIN='' ;;
|
||||
esac
|
||||
{
|
||||
echo "name=$NAME"
|
||||
echo "app_id=$APP_ID"
|
||||
echo "origin=$ORIGIN"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "Building $NAME ($APP_ID) default origin: ${ORIGIN:-production}"
|
||||
|
||||
- name: Bundle main and preload
|
||||
working-directory: apps/desktop
|
||||
env:
|
||||
@@ -177,9 +191,24 @@ jobs:
|
||||
- name: Upload artifacts to the release
|
||||
if: ${{ inputs.publish }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DESKTOP_RELEASE_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }}
|
||||
RELEASE_REPOSITORY: ${{ steps.channel.outputs.release_repository }}
|
||||
SOURCE_RELEASE_TOKEN: ${{ github.token }}
|
||||
TOKEN_KIND: ${{ steps.channel.outputs.token_kind }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
case "$TOKEN_KIND" in
|
||||
prerelease) GH_TOKEN="$DESKTOP_RELEASE_TOKEN" ;;
|
||||
stable) GH_TOKEN="$SOURCE_RELEASE_TOKEN" ;;
|
||||
*)
|
||||
echo "::error::Unknown desktop release token kind: $TOKEN_KIND"
|
||||
exit 1 ;;
|
||||
esac
|
||||
if [ -z "$GH_TOKEN" ]; then
|
||||
echo "::error::No GitHub token is available to publish to $RELEASE_REPOSITORY."
|
||||
exit 1
|
||||
fi
|
||||
export GH_TOKEN
|
||||
# electron-builder's GitHub provider always names the manifest
|
||||
# latest-mac.yml (channels are a generic-provider concept), and the
|
||||
# update feed expects exactly that asset name on every release —
|
||||
@@ -198,6 +227,7 @@ jobs:
|
||||
apps/desktop/release/*.zip \
|
||||
apps/desktop/release/*.blockmap \
|
||||
apps/desktop/release/latest-mac.yml \
|
||||
--repo "$RELEASE_REPOSITORY" \
|
||||
--clobber
|
||||
|
||||
- name: Upload artifacts to the workflow run
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
name: Publish Sim API CLI Package
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, staging, dev]
|
||||
paths:
|
||||
- 'packages/sim-cli/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: publish-sim-cli-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
publish-npm:
|
||||
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
node_modules
|
||||
**/node_modules
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Verify npm authentication
|
||||
env:
|
||||
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: bun pm whoami
|
||||
|
||||
- name: Run tests
|
||||
working-directory: packages/sim-cli
|
||||
run: bun run test
|
||||
|
||||
- name: Type-check package
|
||||
working-directory: packages/sim-cli
|
||||
run: bun run type-check
|
||||
|
||||
- name: Build package
|
||||
working-directory: packages/sim-cli
|
||||
run: bun run build
|
||||
|
||||
- name: Resolve release channel
|
||||
id: release
|
||||
working-directory: packages/sim-cli
|
||||
env:
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
run: |
|
||||
BASE_VERSION="$(bun -p "require('./package.json').version")"
|
||||
if [[ ! "$BASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Package version must be a stable X.Y.Z base, got '$BASE_VERSION'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$BRANCH" in
|
||||
dev)
|
||||
VERSION="${BASE_VERSION}-dev.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}"
|
||||
TAG="dev"
|
||||
;;
|
||||
staging)
|
||||
VERSION="${BASE_VERSION}-preview.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}"
|
||||
TAG="staging"
|
||||
;;
|
||||
main)
|
||||
VERSION="$BASE_VERSION"
|
||||
TAG="latest"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported release branch '$BRANCH'." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
bun pm pkg set "version=$VERSION"
|
||||
RESOLVED_VERSION="$(bun -p "require('./package.json').version")"
|
||||
if [ "$RESOLVED_VERSION" != "$VERSION" ]; then
|
||||
echo "Version injection mismatch: wanted '$VERSION', got '$RESOLVED_VERSION'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "version=$VERSION"
|
||||
echo "tag=$TAG"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Smoke-test packed Node bundle
|
||||
working-directory: packages/sim-cli
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SMOKE_DIR="$(mktemp -d "$RUNNER_TEMP/sim-cli-smoke.XXXXXX")"
|
||||
PACKAGE_PATH="$SMOKE_DIR/sim-cli.tgz"
|
||||
bun pm pack --ignore-scripts --filename "$PACKAGE_PATH" --quiet
|
||||
tar -xzf "$PACKAGE_PATH" -C "$SMOKE_DIR"
|
||||
"$SMOKE_DIR/package/dist/index.js" --version
|
||||
|
||||
- name: Check if version already exists
|
||||
id: version_check
|
||||
working-directory: packages/sim-cli
|
||||
env:
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
run: |
|
||||
if bun pm view "sim@$VERSION" version > /dev/null 2>&1; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Publish to npm
|
||||
if: steps.version_check.outputs.exists == 'false'
|
||||
working-directory: packages/sim-cli
|
||||
env:
|
||||
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_TAG: ${{ steps.release.outputs.tag }}
|
||||
run: bun publish --access public --tag "$NPM_TAG" --no-save
|
||||
|
||||
- name: Summarize release
|
||||
if: steps.version_check.outputs.exists == 'false'
|
||||
env:
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
NPM_TAG: ${{ steps.release.outputs.tag }}
|
||||
run: echo "Published sim@$VERSION with the '$NPM_TAG' tag."
|
||||
|
||||
- name: Summarize skipped release
|
||||
if: steps.version_check.outputs.exists == 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
run: echo "Skipped sim@$VERSION because that version is already published."
|
||||
@@ -264,4 +264,4 @@ jobs:
|
||||
AWS_REGION: 'us-west-2'
|
||||
ENCRYPTION_KEY: '7cf672e460e430c1fba707575c2b0e2ad5a99dddf9b7b7e3b5646e630861db1c' # dummy key for CI only
|
||||
TURBO_CACHE_DIR: .turbo
|
||||
run: bunx turbo run build --filter=sim
|
||||
run: bunx turbo run build --filter=@sim/app
|
||||
|
||||
@@ -104,7 +104,7 @@ Pre-release share (no Developer ID yet): `SIM_DESKTOP_DEFAULT_ORIGIN=https://www
|
||||
The build also derives the app icon from `SIM_DESKTOP_DEFAULT_ORIGIN`. Every channel uses the exact production icon with its white background and black `sim` mark. Non-production channels add a thin outline using existing platform colors: dev uses orange, staging uses Loop blue, and localhost uses Workflow violet. The macOS menu-bar icon also carries a compact `D`, `S`, or `L` subscript for those environments; production remains unmarked. Native Icon Composer assets live in `build/`; `scripts/build.ts` copies the selected variant to the ignored `build/generated-icon.icon` path consumed by electron-builder. Electron-builder compiles it to `Assets.car` and derives the legacy `.icns` fallback from the same source. Matching 512px PNGs in `static/` provide the Dock icon for unpackaged runs.
|
||||
|
||||
CI (`.github/workflows/desktop-release.yml`, wired into `ci.yml`):
|
||||
- Runs only after `create-release` on a `vX.Y.Z:` commit to main — **never before**: `scripts/create-single-release.ts` skips creation if the tag exists, so a desktop job publishing first would eat the changelog. The job builds `--publish never` and uploads assets with `gh release upload --clobber` (idempotent re-runs).
|
||||
- Stable builds run only after `create-release` on a `vX.Y.Z:` commit to main — **never before**: `scripts/create-single-release.ts` skips creation if the tag exists, so a desktop job publishing first would eat the changelog. Stable assets remain on `simstudioai/sim`; dev/staging assets publish to the public `simstudioai/sim-desktop-releases` repository so source-repository followers are not notified for internal shell builds. The job builds `--publish never` and uploads assets with `gh release upload --clobber` (idempotent re-runs).
|
||||
- **Secrets gate**: `check-desktop-signing` in `ci.yml` probes the six Apple secrets and skips the desktop job with a warning until they exist — releases never fail on a missing Apple account, and the first release after the secrets land ships desktop artifacts automatically. Manual/one-off builds: Actions → "Desktop Release (macOS)" → Run workflow with a `vX.Y.Z` version (`publish: false` uploads artifacts to the run instead of the release).
|
||||
- The product semver is **injected** from the release tag into `apps/desktop/package.json` at build time (repo package versions are placeholders). A mismatch guard fails the build.
|
||||
- Fuses are flipped at package time (`electronFuses` in `electron-builder.yml`): runAsNode off, NODE_OPTIONS off, inspect args off, ASAR-only + integrity validation, cookie encryption on, `strictlyRequireAllFuses` so new fuses fail loudly on Electron bumps.
|
||||
@@ -120,6 +120,7 @@ Required repo secrets (owner: whoever holds the Apple Developer account; calenda
|
||||
| `APPLE_API_KEY_ID` | API key ID |
|
||||
| `APPLE_API_ISSUER` | API issuer ID |
|
||||
| `APPLE_TEAM_ID` | Developer team ID |
|
||||
| `DESKTOP_RELEASE_TOKEN` | Fine-grained GitHub token with `Contents: write` on only `simstudioai/sim-desktop-releases`; used to create, upload, publish, and prune dev/staging releases |
|
||||
|
||||
## Desktop-only features (how to add them cleanly)
|
||||
|
||||
@@ -168,7 +169,7 @@ Raw local file bytes are never exposed through the preload bridge and cannot be
|
||||
|
||||
## Auto-update, channels, rollout, rollback
|
||||
|
||||
- `electron-updater` reads the GitHub Releases feed (`publish` is pinned to `simstudioai/sim`); deltas via `.zip.blockmap`. Install is prompt-based (Restart Now / Later; Later installs on quit) — never forced mid-session.
|
||||
- `electron-updater` reads the deployment's `/api/desktop/update` feed; production resolves stable releases from `simstudioai/sim`, while dev/staging resolve prereleases from `simstudioai/sim-desktop-releases`. Artifact downloads go directly to GitHub and deltas use `.zip.blockmap`. Install is prompt-based (Restart Now / Later; Later installs on quit) — never forced mid-session.
|
||||
- Streams: production follows stable `X.Y.Z` releases, dev follows `-dev.N`, and staging follows `-staging.N`. The feed still recognizes legacy `-alpha.N`/`-beta.N` releases during migration.
|
||||
- Staged rollout: after publishing, edit `stagingPercentage: 10` into the release's `latest-mac.yml`, then raise as crash metrics stay clean.
|
||||
- Rollback: a pulled release must be superseded by a **higher** version — users on the broken build will not reinstall an equal one. (A blocked-versions kill-switch was removed as unwired dead code; reintroduce it in `updater.ts` if a remote config source ever exists to feed it.)
|
||||
|
||||
@@ -1121,6 +1121,20 @@ describe('credential protection', () => {
|
||||
expect(cdpCalls(contents, 'Input.dispatchKeyEvent').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('still dispatches input after the user has interacted with the visible tab', async () => {
|
||||
const contents = await openPage()
|
||||
respondWith(contents, { activeElementSecrecy: 'safe', readActiveElementState: {} })
|
||||
// User interaction claims the visible tab for panel-level ownership
|
||||
// (popups, close protection) but must never block agent input.
|
||||
session.claimActiveTabForUser()
|
||||
expect(session.automationTabClaimedByUser()).toBe(true)
|
||||
|
||||
const result = await driver.executeTool('chat-test', 'browser_press_key', { key: 'a' })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(cdpCalls(contents, 'Input.dispatchKeyEvent').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('sends the keystroke when nothing sensitive is focused', async () => {
|
||||
const contents = await openPage()
|
||||
respondWith(contents, { activeElementSecrecy: 'safe', readActiveElementState: {} })
|
||||
|
||||
@@ -29,7 +29,7 @@ import type { BrowserDownloadsState, BrowserToolbarCommand } from '@sim/desktop-
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { isRecordLike, omit } from '@sim/utils/object'
|
||||
import { isRecordLike, omit, toRecord } from '@sim/utils/object'
|
||||
import type { BrowserWindow, MenuItemConstructorOptions, WebContents, WebFrameMain } from 'electron'
|
||||
import { Menu } from 'electron'
|
||||
import * as cdp from '@/main/browser-agent/cdp'
|
||||
@@ -1055,7 +1055,7 @@ function raceAgainstWatchdog<T>(
|
||||
*/
|
||||
async function activeElementState(target: PageExecutionTarget): Promise<Record<string, unknown>> {
|
||||
const state = await execInPage(target, readActiveElementState, []).catch(() => null)
|
||||
return isRecordLike(state) ? state : {}
|
||||
return toRecord(state)
|
||||
}
|
||||
|
||||
function requireSnapshotForElementAction(): void {
|
||||
@@ -1077,10 +1077,14 @@ function pageTargetForElement(contents: WebContents, elementId: number): PageExe
|
||||
return target
|
||||
}
|
||||
|
||||
// The user's claim on the visible tab (visibleTabUserSelected) deliberately
|
||||
// does NOT gate input here: the user clicking or typing in the panel must
|
||||
// never leave the agent unable to act — hand-off is cooperative via
|
||||
// browser_request_takeover, whose serialized tool slot already keeps agent
|
||||
// input out while the user drives.
|
||||
function assertActiveContents(contents: WebContents, expectedNavigationEpoch?: number): void {
|
||||
const active = session.automationTab()
|
||||
if (
|
||||
session.automationTabClaimedByUser() ||
|
||||
!active ||
|
||||
active.view.webContents !== contents ||
|
||||
contents.isDestroyed() ||
|
||||
@@ -1278,7 +1282,7 @@ async function pageActionState(
|
||||
resetMutationRevision,
|
||||
elementId,
|
||||
]).catch(() => null)
|
||||
return isRecordLike(state) ? state : {}
|
||||
return toRecord(state)
|
||||
}
|
||||
|
||||
function pageEffect(
|
||||
@@ -2669,7 +2673,7 @@ async function executeToolInner(
|
||||
},
|
||||
}
|
||||
return {
|
||||
...(isRecordLike(fallback) ? fallback : {}),
|
||||
...fallback,
|
||||
trusted,
|
||||
...state,
|
||||
...combinedObservation,
|
||||
@@ -2955,12 +2959,11 @@ async function executeToolInner(
|
||||
await sleep(50)
|
||||
const state = unwrapPageResult(await execInPage(target, readSelectElementState, [elementId]))
|
||||
const effectObserved =
|
||||
isRecordLike(selected) &&
|
||||
isRecordLike(state) &&
|
||||
selected.selected === state.selected &&
|
||||
selected.value === state.value
|
||||
return {
|
||||
...(isRecordLike(selected) ? selected : {}),
|
||||
...selected,
|
||||
effectObserved,
|
||||
readback: state,
|
||||
...(!effectObserved
|
||||
@@ -3100,7 +3103,7 @@ async function executeToolInner(
|
||||
: {}),
|
||||
}
|
||||
return {
|
||||
...(isRecordLike(result) ? result : {}),
|
||||
...result,
|
||||
trusted,
|
||||
effect,
|
||||
possibleEffectObserved,
|
||||
|
||||
@@ -21,6 +21,7 @@ interface MockView {
|
||||
setPermissionCheckHandler: ReturnType<typeof vi.fn>
|
||||
}
|
||||
on: ReturnType<typeof vi.fn>
|
||||
setUserAgent: ReturnType<typeof vi.fn>
|
||||
setWindowOpenHandler: ReturnType<typeof vi.fn>
|
||||
loadURL: ReturnType<typeof vi.fn>
|
||||
reload: ReturnType<typeof vi.fn>
|
||||
@@ -169,6 +170,18 @@ describe('browser-agent session', () => {
|
||||
expect(onTabNavigated).toHaveBeenCalledWith(contents, true)
|
||||
})
|
||||
|
||||
it('gives every tab a user agent with no Electron token in it', () => {
|
||||
const first = session.ensureTab()
|
||||
const second = session.addTab()
|
||||
|
||||
for (const tab of [first, second]) {
|
||||
const contents = (tab.view as unknown as MockView).webContents
|
||||
const agent = contents.setUserAgent.mock.calls.at(-1)?.[0] as string | undefined
|
||||
expect(agent).toMatch(/^Mozilla\/5\.0 \(.+\) .*Chrome\/\d+\.0\.0\.0 Safari\/537\.36$/)
|
||||
expect(agent).not.toMatch(/Electron|Sim\//)
|
||||
}
|
||||
})
|
||||
|
||||
it('settles the tab spinner when only subresources are still loading', () => {
|
||||
const tab = session.ensureTab()
|
||||
const contents = (tab.view as unknown as MockView).webContents
|
||||
@@ -1757,7 +1770,7 @@ describe('browser-agent session', () => {
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('permission handlers deny every request on the agent partition', () => {
|
||||
it('permission handlers deny every request on the agent partition but the copy button', () => {
|
||||
const tab = session.ensureTab()
|
||||
const ses = (tab.view as unknown as MockView).webContents.session
|
||||
const requestHandler = ses.setPermissionRequestHandler.mock.calls[0][0] as (
|
||||
@@ -1765,12 +1778,26 @@ describe('browser-agent session', () => {
|
||||
permission: string,
|
||||
callback: (granted: boolean) => void
|
||||
) => void
|
||||
const callback = vi.fn()
|
||||
requestHandler(null, 'media', callback)
|
||||
expect(callback).toHaveBeenCalledWith(false)
|
||||
const checkHandler = ses.setPermissionCheckHandler.mock.calls[0][0] as (
|
||||
wc: unknown,
|
||||
permission: string
|
||||
) => boolean
|
||||
|
||||
const checkHandler = ses.setPermissionCheckHandler.mock.calls[0][0] as () => boolean
|
||||
expect(checkHandler()).toBe(false)
|
||||
// Reading the clipboard would leak whatever the user last copied anywhere
|
||||
// else, so it stays denied alongside everything a page could spy through.
|
||||
for (const permission of ['media', 'geolocation', 'notifications', 'clipboard-read']) {
|
||||
const callback = vi.fn()
|
||||
requestHandler(null, permission, callback)
|
||||
expect(callback).toHaveBeenCalledWith(false)
|
||||
expect(checkHandler(null, permission)).toBe(false)
|
||||
}
|
||||
|
||||
// Chromium routes navigator.clipboard.writeText through this one; denying
|
||||
// it silently broke every copy button that does not use execCommand.
|
||||
const writeCallback = vi.fn()
|
||||
requestHandler(null, 'clipboard-sanitized-write', writeCallback)
|
||||
expect(writeCallback).toHaveBeenCalledWith(true)
|
||||
expect(checkHandler(null, 'clipboard-sanitized-write')).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves nothing of the signed-out user behind in the browser profile', async () => {
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
isBlockedSubresourceUrl,
|
||||
subresourceNeedsResolution,
|
||||
} from '@/main/browser-agent/url-guard'
|
||||
import { browserUserAgent } from '@/main/browser-agent/user-agent'
|
||||
import type { BrowserSessionSnapshot } from '@/main/desktop-chat-session-store'
|
||||
import { suggestedFilename, uniqueDownloadPath } from '@/main/downloads'
|
||||
import {
|
||||
@@ -809,16 +810,40 @@ export async function importAgentCookies(
|
||||
return { imported, failed }
|
||||
}
|
||||
|
||||
/**
|
||||
* The single site permission a browsing surface cannot withhold: the one every
|
||||
* "Copy" button on the web goes through. Blanket-denying it made
|
||||
* `navigator.clipboard.writeText` reject with `NotAllowedError`, so those
|
||||
* buttons did nothing at all — no error, no copied text — while the legacy
|
||||
* `document.execCommand('copy')` path kept working, which is why only some
|
||||
* sites looked broken.
|
||||
*
|
||||
* Granting it hands the page no reach it lacked: Chromium still requires the
|
||||
* document to be focused and to hold a transient user activation, and a
|
||||
* sanitized write only places text the page already renders onto the clipboard.
|
||||
* Reading stays denied — that is the direction that would leak whatever the
|
||||
* user last copied from anywhere else.
|
||||
*/
|
||||
const ALLOWED_SITE_PERMISSIONS = new Set(['clipboard-sanitized-write'])
|
||||
|
||||
/**
|
||||
* Default-deny hardening for the agent partition. Site permissions remain
|
||||
* denied, while uploads use Chromium's native file chooser and downloads are
|
||||
* saved into the device-level browser download directory.
|
||||
* denied apart from ALLOWED_SITE_PERMISSIONS, while uploads use Chromium's
|
||||
* native file chooser and downloads are saved into the device-level browser
|
||||
* download directory.
|
||||
*/
|
||||
function configureAgentPartition(ses: Session): void {
|
||||
if (configuredPartitions.has(ses)) return
|
||||
configuredPartitions.add(ses)
|
||||
ses.setPermissionRequestHandler((_wc, _permission, callback) => callback(false))
|
||||
ses.setPermissionCheckHandler(() => false)
|
||||
ses.setPermissionRequestHandler((_wc, permission, callback) =>
|
||||
callback(ALLOWED_SITE_PERMISSIONS.has(permission))
|
||||
)
|
||||
ses.setPermissionCheckHandler((_wc, permission) => ALLOWED_SITE_PERMISSIONS.has(permission))
|
||||
// Service workers do not inherit a tab's user agent. With only the tab's set,
|
||||
// the document request carries the browser string while the worker's own
|
||||
// script request still announces Electron — and on a site that routes its
|
||||
// fetches through a worker, that is the one the server sees.
|
||||
ses.setUserAgent(browserUserAgent())
|
||||
// SSRF choke point for the agent partition. Document navigations (top-level +
|
||||
// iframes) get the full DNS-resolving check — the one seam every navigation
|
||||
// passes through, including page-initiated ones the driver never sees (server
|
||||
@@ -1101,6 +1126,10 @@ function createTabView(): WebContentsView {
|
||||
const contents = view.webContents
|
||||
registerAgentWebContents(contents)
|
||||
configureAgentPartition(contents.session)
|
||||
// The session default does not reach a WebContents that already exists, and
|
||||
// the first tab is what brings the session into being, so each tab sets its
|
||||
// own as well — otherwise tab one browses as Electron and the rest as Chrome.
|
||||
contents.setUserAgent(browserUserAgent())
|
||||
attachAgentContextMenu(contents, {
|
||||
addToChat: (text) => withBrowserScope(scopeId, () => addPageSelectionToChat(contents, text)),
|
||||
openTab: (url) => withBrowserScope(scopeId, () => openTabWithUrl(url, false)),
|
||||
@@ -2165,6 +2194,13 @@ export function automationTab(): AgentTab | null {
|
||||
return tab
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user has interacted with the visible tab while it is also the
|
||||
* agent's automation tab. Governs panel-level ownership only — popup
|
||||
* adoption and protection against the agent closing the tab out from under
|
||||
* the user. It must never gate agent input: the user clicking or typing in
|
||||
* the panel does not revoke the agent's ability to act there.
|
||||
*/
|
||||
export function automationTabClaimedByUser(): boolean {
|
||||
return (
|
||||
currentScope.visibleTabUserSelected &&
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { app } from 'electron'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { browserUserAgent, stockChromeUserAgent } from '@/main/browser-agent/user-agent'
|
||||
|
||||
vi.mock('electron', () => import('@/test/electron-mock'))
|
||||
|
||||
const ELECTRON_DEFAULT =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Sim/1.0.0 Chrome/140.0.7339.207 Electron/43.1.1 Safari/537.36'
|
||||
|
||||
describe('stockChromeUserAgent', () => {
|
||||
it('drops the application and Electron tokens a browser allowlist rejects', () => {
|
||||
const agent = stockChromeUserAgent(ELECTRON_DEFAULT)
|
||||
expect(agent).not.toMatch(/Electron/)
|
||||
expect(agent).not.toMatch(/Sim\//)
|
||||
})
|
||||
|
||||
it('reproduces the desktop string Chrome sends under user-agent reduction', () => {
|
||||
expect(stockChromeUserAgent(ELECTRON_DEFAULT)).toBe(
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the platform token of the machine it is running on', () => {
|
||||
const windowsDefault =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Sim/1.0.0 Chrome/140.0.7339.207 Electron/43.1.1 Safari/537.36'
|
||||
expect(stockChromeUserAgent(windowsDefault)).toContain('(Windows NT 10.0; Win64; x64)')
|
||||
})
|
||||
|
||||
it('passes through a string that is not a Chromium user agent', () => {
|
||||
expect(stockChromeUserAgent('curl/8.4.0')).toBe('curl/8.4.0')
|
||||
expect(stockChromeUserAgent('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('browserUserAgent', () => {
|
||||
it('derives from the string Electron would otherwise have sent', () => {
|
||||
app.userAgentFallback = ELECTRON_DEFAULT
|
||||
|
||||
expect(browserUserAgent()).toBe(
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* The user agent the browser resource presents to sites.
|
||||
*
|
||||
* Electron's default string carries two tokens no browser sends —
|
||||
* `Sim/<version>` and `Electron/<version>`. Chromium's own token sits right
|
||||
* beside them, but that does not save it: the detection libraries sites gate on
|
||||
* test for Electron BEFORE Chrome (bowser matches `/electron/i` several
|
||||
* descriptors ahead of its Chrome one, ua-parser-js reports `Electron` as the
|
||||
* browser name), so the browser reads as "Electron", which is on nobody's
|
||||
* supported list. Ashby warns "Ashby does not support this browser"; stricter
|
||||
* sites refuse to render at all.
|
||||
*
|
||||
* Reporting stock Chrome is accurate rather than a disguise — the engine is the
|
||||
* Chromium build the token already names, and Electron's user-agent client
|
||||
* hints (`Sec-CH-UA`, `navigator.userAgentData`) only ever carried a Chromium
|
||||
* brand, so dropping the token makes the header and the hints agree instead of
|
||||
* contradicting each other.
|
||||
*/
|
||||
import { app } from 'electron'
|
||||
|
||||
/** Platform token, then the Chromium major version, in the order a Chromium user agent lists them. */
|
||||
const CHROMIUM_USER_AGENT = /^Mozilla\/5\.0 \(([^)]*)\).* Chrome\/(\d+)\./
|
||||
|
||||
/**
|
||||
* Rebuilds the default user agent as the string Chrome itself sends. Chrome's
|
||||
* user-agent reduction fixes the desktop form at
|
||||
* `Mozilla/5.0 (<platform>) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/<major>.0.0.0 Safari/537.36`,
|
||||
* so keeping the platform token and the Chromium major version — and zeroing
|
||||
* the rest — reproduces it exactly, with no room left for an application or
|
||||
* Electron token. A string that is not a Chromium user agent is returned
|
||||
* unchanged rather than replaced with a guess.
|
||||
*/
|
||||
export function stockChromeUserAgent(defaultUserAgent: string): string {
|
||||
const match = defaultUserAgent.match(CHROMIUM_USER_AGENT)
|
||||
if (!match) return defaultUserAgent
|
||||
const [, platform, chromeMajor] = match
|
||||
return `Mozilla/5.0 (${platform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeMajor}.0.0.0 Safari/537.36`
|
||||
}
|
||||
|
||||
/**
|
||||
* Derived from the string Electron would otherwise have sent, so the reported
|
||||
* Chromium version tracks whatever Chromium the app actually ships.
|
||||
*/
|
||||
export function browserUserAgent(): string {
|
||||
return stockChromeUserAgent(app.userAgentFallback)
|
||||
}
|
||||
@@ -77,23 +77,6 @@ export function userDataDirFor(source: BrowserSource, home: string = homedir()):
|
||||
return join(home, ...source.userDataSegments)
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a bridge profile id back into its browser and profile directory.
|
||||
*
|
||||
* Ids are namespaced (`arc:Profile 1`) because profile directory names repeat
|
||||
* across browsers — every one of them has a `Default`. Returns null for
|
||||
* anything malformed; the caller then resolves against discovered profiles
|
||||
* anyway, so a bad id can never become a path.
|
||||
*/
|
||||
export function parseProfileId(profileId: string): { sourceId: string; directory: string } | null {
|
||||
const separator = profileId.indexOf(':')
|
||||
if (separator <= 0 || separator === profileId.length - 1) return null
|
||||
return {
|
||||
sourceId: profileId.slice(0, separator),
|
||||
directory: profileId.slice(separator + 1),
|
||||
}
|
||||
}
|
||||
|
||||
export function formatProfileId(sourceId: string, directory: string): string {
|
||||
return `${sourceId}:${directory}`
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { constants } from 'node:fs'
|
||||
import { access, lstat, readdir, readFile, realpath } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { isRecordLike } from '@sim/utils/object'
|
||||
import {
|
||||
BROWSER_SOURCES,
|
||||
type BrowserSource,
|
||||
@@ -134,7 +135,7 @@ async function readProfileDisplayNames(userDataDir: string): Promise<Map<string,
|
||||
const raw = await readFile(join(userDataDir, 'Local State'), 'utf8')
|
||||
const infoCache = (JSON.parse(raw) as { profile?: { info_cache?: unknown } }).profile
|
||||
?.info_cache
|
||||
if (infoCache && typeof infoCache === 'object' && !Array.isArray(infoCache)) {
|
||||
if (isRecordLike(infoCache)) {
|
||||
for (const [dir, info] of Object.entries(infoCache as Record<string, unknown>)) {
|
||||
if (!PROFILE_DIR_PATTERN.test(dir)) continue
|
||||
const name = (info as { name?: unknown })?.name
|
||||
|
||||
Binary file not shown.
@@ -32,7 +32,6 @@ import {
|
||||
type TerminalToolResponse,
|
||||
} from '@sim/terminal-protocol'
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { isRecordLike } from '@sim/utils/object'
|
||||
import type { BrowserWindow, WebContents } from 'electron'
|
||||
import {
|
||||
type FocusedResourceShortcut,
|
||||
@@ -1201,8 +1200,3 @@ export class TerminalService {
|
||||
function unknownTerminal(terminalId: string): string {
|
||||
return `No terminal with id ${terminalId}. Call terminal_list for the open ones.`
|
||||
}
|
||||
|
||||
/** Narrows an IPC payload to the tool-call shape without trusting the sender. */
|
||||
export function parseToolParams(value: unknown): Record<string, unknown> {
|
||||
return isRecordLike(value) ? value : {}
|
||||
}
|
||||
|
||||
@@ -391,15 +391,15 @@ describe('initUpdater state machine', () => {
|
||||
})
|
||||
})
|
||||
|
||||
function manifest(version: string): string {
|
||||
function manifest(version: string, repository = 'simstudioai/sim'): string {
|
||||
return [
|
||||
`version: ${version}`,
|
||||
'files:',
|
||||
` - url: https://github.com/simstudioai/sim/releases/download/v${version}/Sim-${version}-universal-mac.zip`,
|
||||
` - url: https://github.com/${repository}/releases/download/v${version}/Sim-${version}-universal-mac.zip`,
|
||||
' sha512: abc',
|
||||
` - url: https://github.com/simstudioai/sim/releases/download/v${version}/Sim-${version}-universal.dmg`,
|
||||
` - url: https://github.com/${repository}/releases/download/v${version}/Sim-${version}-universal.dmg`,
|
||||
' sha512: def',
|
||||
`path: https://github.com/simstudioai/sim/releases/download/v${version}/Sim-${version}-universal-mac.zip`,
|
||||
`path: https://github.com/${repository}/releases/download/v${version}/Sim-${version}-universal-mac.zip`,
|
||||
"releaseDate: '2026-07-23T00:00:00.000Z'",
|
||||
].join('\n')
|
||||
}
|
||||
@@ -453,6 +453,26 @@ describe('initUpdater manual mode (no Developer ID signature)', () => {
|
||||
expect(shell.openExternal).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('offers prerelease-repository assets as manual downloads', async () => {
|
||||
const fetchManifest = vi.fn(async () =>
|
||||
manifest('9.9.9-dev.1', 'simstudioai/sim-desktop-releases')
|
||||
)
|
||||
const { handle } = await createManualUpdater(fetchManifest)
|
||||
|
||||
handle.check()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(handle.getState()).toEqual({
|
||||
status: 'available',
|
||||
version: '9.9.9-dev.1',
|
||||
manual: true,
|
||||
})
|
||||
|
||||
handle.check()
|
||||
expect(shell.openExternal).toHaveBeenCalledWith(
|
||||
'https://github.com/simstudioai/sim-desktop-releases/releases/download/v9.9.9-dev.1/Sim-9.9.9-dev.1-universal.dmg'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a manifest whose download urls are not http(s)', async () => {
|
||||
const hostile = [
|
||||
'version: 9.9.9',
|
||||
@@ -499,6 +519,19 @@ describe('initUpdater manual mode (no Developer ID signature)', () => {
|
||||
expect(shell.openExternal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses assets from other repositories on github.com', async () => {
|
||||
const offRepository = manifest('9.9.9', 'simstudioai/not-desktop-releases')
|
||||
const { handle } = await createManualUpdater(async () => offRepository)
|
||||
|
||||
handle.check()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(handle.getState()).toMatchObject({ status: 'error', manual: true })
|
||||
handle.check()
|
||||
handle.install()
|
||||
expect(shell.openExternal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips an unusable url but still offers a safe one from the same manifest', async () => {
|
||||
const mixed = [
|
||||
'version: 9.9.9',
|
||||
|
||||
@@ -43,7 +43,10 @@ export function feedUrlForOrigin(origin: string): string | null {
|
||||
* host cannot get a bundle in front of the user's Download button.
|
||||
*/
|
||||
const RELEASE_ASSET_ORIGIN = 'https://github.com'
|
||||
const RELEASE_ASSET_PATH = '/simstudioai/sim/releases/download/'
|
||||
const RELEASE_ASSET_PATHS = [
|
||||
'/simstudioai/sim/releases/download/',
|
||||
'/simstudioai/sim-desktop-releases/releases/download/',
|
||||
] as const
|
||||
|
||||
/** Whether a manifest url is one of our own release assets. */
|
||||
function isReleaseAssetUrl(rawUrl: string): boolean {
|
||||
@@ -53,7 +56,10 @@ function isReleaseAssetUrl(rawUrl: string): boolean {
|
||||
// Compared on the parsed origin and the parsed pathname, never by prefix on
|
||||
// the raw string: `https://github.com.evil.example/…` must not pass, and
|
||||
// `URL` has already normalized away any `..` segments by this point.
|
||||
return url.origin === RELEASE_ASSET_ORIGIN && url.pathname.startsWith(RELEASE_ASSET_PATH)
|
||||
return (
|
||||
url.origin === RELEASE_ASSET_ORIGIN &&
|
||||
RELEASE_ASSET_PATHS.some((path) => url.pathname.startsWith(path))
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import { vi } from 'vitest'
|
||||
export const app = {
|
||||
name: 'Sim',
|
||||
isPackaged: false,
|
||||
userAgentFallback:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Sim/1.0.0 Chrome/140.0.7339.207 Electron/43.1.1 Safari/537.36',
|
||||
getVersion: vi.fn(() => '1.0.0'),
|
||||
getName: vi.fn(() => 'Sim'),
|
||||
setName: vi.fn(),
|
||||
@@ -152,6 +154,7 @@ function createWebContentsMock() {
|
||||
findInPage: vi.fn(() => 1),
|
||||
stopFindInPage: vi.fn(),
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
setUserAgent: vi.fn(),
|
||||
setIgnoreMenuShortcuts: vi.fn(),
|
||||
getZoomFactor: vi.fn(() => 1),
|
||||
setZoomFactor: vi.fn(),
|
||||
@@ -185,6 +188,7 @@ function createWebContentsMock() {
|
||||
session: {
|
||||
setPermissionRequestHandler: vi.fn(),
|
||||
setPermissionCheckHandler: vi.fn(),
|
||||
setUserAgent: vi.fn(),
|
||||
webRequest: { onBeforeRequest: vi.fn() },
|
||||
on: vi.fn(),
|
||||
},
|
||||
|
||||
+163
-142
@@ -42,6 +42,13 @@ body {
|
||||
--text-small: 13px;
|
||||
--text-base: 15px;
|
||||
--text-md: 16px;
|
||||
|
||||
/* Code-token size for the API reference — a deliberate sixth step, between
|
||||
--text-caption and --text-small, because the mono face reads small at 12px. */
|
||||
--text-code: 0.78125rem;
|
||||
|
||||
--font-mono-stack: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
/* Pure white light mode background */
|
||||
@@ -134,7 +141,6 @@ body {
|
||||
--selection-dark: #264f78;
|
||||
--highlight-search-active: #f6ad55;
|
||||
--scrollbar-thumb-color: #c0c0c0;
|
||||
--scrollbar-thumb-hover-color: #a8a8a8;
|
||||
--shadow-subtle: 0 2px 4px 0 rgba(0, 0, 0, 0.08);
|
||||
--shadow-medium: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
--shadow-overlay: 0 10px 30px rgba(0, 0, 0, 0.11);
|
||||
@@ -216,34 +222,18 @@ body {
|
||||
--code-line-number: #a8a8a8;
|
||||
--selection-bg: #264f78;
|
||||
--scrollbar-thumb-color: #5a5a5a;
|
||||
--scrollbar-thumb-hover-color: #6a6a6a;
|
||||
--shadow-overlay: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Scrollbars — platform thumb tokens, transparent track */
|
||||
/* Scrollbars — platform thumb tokens, transparent track. A non-auto
|
||||
`scrollbar-width`/`scrollbar-color` makes Chromium ignore every
|
||||
`::-webkit-scrollbar*` rule on the element, so no webkit block here. Hover
|
||||
shading is not expressible through the standard properties. */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb-color) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: var(--scrollbar-thumb-color);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--scrollbar-thumb-hover-color);
|
||||
}
|
||||
|
||||
/* Font family utilities */
|
||||
.font-sans {
|
||||
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
@@ -251,8 +241,7 @@ body {
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
font-family: var(--font-mono-stack);
|
||||
}
|
||||
|
||||
/* Platform UI font — Season Sans, used by the chip chrome to match the main app */
|
||||
@@ -672,8 +661,7 @@ aside[data-sidebar],
|
||||
code,
|
||||
pre,
|
||||
pre code {
|
||||
font-family: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
font-family: var(--font-mono-stack);
|
||||
}
|
||||
|
||||
/* Inline code — neutral colors aligned with sim design system */
|
||||
@@ -912,16 +900,18 @@ video {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Ensure API reference pages use the same font as the rest of the docs */
|
||||
/* Ensure API reference pages use the same font as the rest of the docs.
|
||||
`.font-mono` is excluded: this selector (id + element) outranks the
|
||||
`.font-mono` class rule, so without it every code identifier renders sans. */
|
||||
#nd-page:has(.api-page-header),
|
||||
#nd-page:has(.api-page-header) h2,
|
||||
#nd-page:has(.api-page-header) h3,
|
||||
#nd-page:has(.api-page-header) h4,
|
||||
#nd-page:has(.api-page-header) p,
|
||||
#nd-page:has(.api-page-header) span,
|
||||
#nd-page:has(.api-page-header) div,
|
||||
#nd-page:has(.api-page-header) label,
|
||||
#nd-page:has(.api-page-header) button {
|
||||
#nd-page:has(.api-page-header) p:not(.font-mono),
|
||||
#nd-page:has(.api-page-header) span:not(.font-mono),
|
||||
#nd-page:has(.api-page-header) div:not(.font-mono),
|
||||
#nd-page:has(.api-page-header) label:not(.font-mono),
|
||||
#nd-page:has(.api-page-header) button:not(.font-mono) {
|
||||
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
@@ -1162,23 +1152,45 @@ div.flex.flex-row.items-start.bg-fd-secondary.border.rounded-lg.text-xs {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* API-reference metadata face — the status trigger, the content-type label, the
|
||||
`required` / `header` markers, and the status-code tabs. Defined once; each
|
||||
consumer below adds only its own colour, content, and order. The `code.text-xs`
|
||||
label further down needs `!important` to beat fumadocs and stays separate. */
|
||||
#nd-page:has(.api-page-header) button.response-section-dropdown-trigger,
|
||||
.response-section-dropdown-trigger,
|
||||
#nd-page:has(.api-page-header) span.response-section-content-type,
|
||||
.response-section-content-type,
|
||||
#nd-page:has(.api-page-header)
|
||||
.flex.flex-wrap.items-center.gap-3.not-prose:has(span.text-red-400)::after,
|
||||
#nd-page:has(.api-page-header) div.my-4 > .flex.flex-wrap.items-center.gap-3.not-prose::before,
|
||||
#nd-page:has(.api-page-header) div.my-4 > .flex.flex-wrap.items-center.gap-3.not-prose::after,
|
||||
#nd-page:has(.api-page-header) .flex.gap-3\.5.overflow-x-auto.not-prose > button {
|
||||
font-size: var(--text-code);
|
||||
line-height: 1.25rem;
|
||||
font-weight: 400;
|
||||
font-family: var(--font-mono-stack);
|
||||
}
|
||||
|
||||
/* Status-code trigger — matches the content-type label beside it. */
|
||||
#nd-page:has(.api-page-header) button.response-section-dropdown-trigger,
|
||||
.response-section-dropdown-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.125rem 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-fd-muted-foreground);
|
||||
height: 1.25rem;
|
||||
padding: 0 0.25rem;
|
||||
color: var(--text-secondary);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 0.375rem;
|
||||
transition: color 0.15s;
|
||||
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
/* Carries the same id-qualified prefix as the base rule above; without it the
|
||||
base rule outranks this one and the trigger never changes colour on hover. */
|
||||
#nd-page:has(.api-page-header) button.response-section-dropdown-trigger:hover,
|
||||
.response-section-dropdown-trigger:hover {
|
||||
color: var(--color-fd-foreground);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.response-section-chevron {
|
||||
@@ -1226,7 +1238,7 @@ div.flex.flex-row.items-start.bg-fd-secondary.border.rounded-lg.text-xs {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.response-section-dropdown-item-selected {
|
||||
color: var(--color-fd-foreground);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.response-section-check {
|
||||
@@ -1234,10 +1246,15 @@ div.flex.flex-row.items-start.bg-fd-secondary.border.rounded-lg.text-xs {
|
||||
height: 0.875rem;
|
||||
}
|
||||
|
||||
/* Content-type label. The Response header renders this class; the Request Body
|
||||
header renders a fumadocs `code.text-xs`. Keep the two in sync — the same
|
||||
string at different weights reads as one being lighter than the other. */
|
||||
#nd-page:has(.api-page-header) span.response-section-content-type,
|
||||
.response-section-content-type {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-fd-muted-foreground);
|
||||
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
color: var(--text-secondary);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Response schema container — remove border to match Path Parameters style */
|
||||
@@ -1262,25 +1279,80 @@ div.flex.flex-row.items-start.bg-fd-secondary.border.rounded-lg.text-xs {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
/* Type badge — order 2, grey pill */
|
||||
/* Type token — order 2. Covers every shape the slot takes: scalar span, union
|
||||
wrapper, schema-reference button, and the auth row's `::after` label. Reuses
|
||||
the docs inline-code recipe, so a type reads as code wherever it appears; the
|
||||
explicit 20px height keeps a union level with a scalar, which its nested
|
||||
links would otherwise push to 26px. */
|
||||
#nd-page:has(.api-page-header)
|
||||
.flex.flex-wrap.items-center.gap-3.not-prose
|
||||
> span.text-sm.font-mono.text-fd-muted-foreground {
|
||||
> span.text-sm.font-mono.text-fd-muted-foreground,
|
||||
#nd-page:has(.api-page-header) .flex.flex-wrap.items-center.gap-3.not-prose > button,
|
||||
#nd-page:has(.api-page-header) .flex.flex-wrap.items-center.gap-3.not-prose > span:has(> button),
|
||||
#nd-page:has(.api-page-header)
|
||||
div.my-4
|
||||
> .flex.flex-wrap.items-center.gap-3.not-prose
|
||||
> span.text-sm.font-mono.text-fd-muted-foreground::after {
|
||||
order: 2;
|
||||
background-color: var(--surface-5);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.125rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
html.dark
|
||||
#nd-page:has(.api-page-header)
|
||||
.flex.flex-wrap.items-center.gap-3.not-prose
|
||||
> span.text-sm.font-mono.text-fd-muted-foreground {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 1.25rem;
|
||||
/* No gap: an `array<T>` slot holds its brackets as bare text nodes, which
|
||||
become anonymous flex items, so any gap here would prise `array<` and `>`
|
||||
away from the type they wrap. The union separator spaces itself instead. */
|
||||
gap: 0;
|
||||
background-color: var(--surface-4);
|
||||
border: 1px solid var(--border-1);
|
||||
color: var(--text-body);
|
||||
padding: 0 0.3125rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: var(--text-code);
|
||||
line-height: 1;
|
||||
font-weight: 400;
|
||||
font-family: var(--font-mono-stack);
|
||||
}
|
||||
|
||||
/* Everything inside a type token inherits the token's own face, size, and ink.
|
||||
Applied to every descendant, not just the links: a union's `|` separator is a
|
||||
classless `span`, so the page-wide `span:not(.font-mono)` rule assigned it the
|
||||
body sans face and one chip rendered in two faces. Anything fumadocs nests in
|
||||
here later is covered by the same reset.
|
||||
Underline is deferred to hover so links don't read heavier than a plain scalar
|
||||
in the same box. The button that *is* the slot needs its own rule below: it
|
||||
cannot `inherit`, which would pull the row's 14px sans back in. */
|
||||
#nd-page:has(.api-page-header)
|
||||
.flex.flex-wrap.items-center.gap-3.not-prose
|
||||
> span.text-sm.font-mono.text-fd-muted-foreground
|
||||
* {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
#nd-page:has(.api-page-header)
|
||||
.flex.flex-wrap.items-center.gap-3.not-prose
|
||||
> button.text-sm.font-mono.text-fd-muted-foreground {
|
||||
text-decoration: none;
|
||||
}
|
||||
#nd-page:has(.api-page-header)
|
||||
.flex.flex-wrap.items-center.gap-3.not-prose
|
||||
> span.text-sm.font-mono.text-fd-muted-foreground
|
||||
:is(a, button):hover,
|
||||
#nd-page:has(.api-page-header)
|
||||
.flex.flex-wrap.items-center.gap-3.not-prose
|
||||
> button.text-sm.font-mono.text-fd-muted-foreground:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Union separator — dimmed one step, no further: `string | null` started
|
||||
reading as `string null` on the chip fill. Own margin; the slot has no gap. */
|
||||
#nd-page:has(.api-page-header)
|
||||
.flex.flex-wrap.items-center.gap-3.not-prose
|
||||
> span.text-sm.font-mono.text-fd-muted-foreground
|
||||
> span {
|
||||
margin: 0 0.375rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Hide the "*" inside the name span — we'll add "required" as a ::after on the flex row */
|
||||
@@ -1288,21 +1360,15 @@ html.dark
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Required badge — order 3, red pill */
|
||||
/* Required marker — order 3. Error text colour but no fill: eight required
|
||||
params on one page should not read as eight alarms. */
|
||||
#nd-page:has(.api-page-header)
|
||||
.flex.flex-wrap.items-center.gap-3.not-prose:has(span.text-red-400)::after {
|
||||
content: "required";
|
||||
order: 3;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background-color: var(--badge-error-bg);
|
||||
color: var(--badge-error-text);
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.125rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
/* Optional "?" indicator — hide it */
|
||||
#nd-page:has(.api-page-header)
|
||||
@@ -1326,79 +1392,52 @@ html.dark
|
||||
> span.font-medium.font-mono.text-fd-primary {
|
||||
order: 1;
|
||||
}
|
||||
/* Auth rows collapse the real `<token>` text to zero and draw the chip in the
|
||||
`::after` below, so this span is a bare wrapper: it must drop the type-token
|
||||
box it matches, or the chip renders inside a second, empty bordered box. */
|
||||
#nd-page:has(.api-page-header)
|
||||
div.my-4
|
||||
> .flex.flex-wrap.items-center.gap-3.not-prose
|
||||
> span.text-sm.font-mono.text-fd-muted-foreground {
|
||||
order: 2;
|
||||
font-size: 0;
|
||||
padding: 0 !important;
|
||||
background: none !important;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
height: auto;
|
||||
line-height: 0;
|
||||
}
|
||||
/* Only the label — the box comes from the shared type-token rule above, which
|
||||
this pseudo-element is a member of. */
|
||||
#nd-page:has(.api-page-header)
|
||||
div.my-4
|
||||
> .flex.flex-wrap.items-center.gap-3.not-prose
|
||||
> span.text-sm.font-mono.text-fd-muted-foreground::after {
|
||||
content: "string";
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.125rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
background-color: var(--surface-5);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
html.dark
|
||||
#nd-page:has(.api-page-header)
|
||||
div.my-4
|
||||
> .flex.flex-wrap.items-center.gap-3.not-prose
|
||||
> span.text-sm.font-mono.text-fd-muted-foreground::after {
|
||||
background-color: var(--surface-4);
|
||||
}
|
||||
|
||||
/* "header" badge via ::before on the auth flex row */
|
||||
/* "header" location via ::before on the auth flex row — uncontained metadata,
|
||||
matching the `required` marker rather than the type token. */
|
||||
#nd-page:has(.api-page-header) div.my-4 > .flex.flex-wrap.items-center.gap-3.not-prose::before {
|
||||
content: "header";
|
||||
order: 3;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background-color: var(--surface-5);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.125rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
html.dark
|
||||
#nd-page:has(.api-page-header)
|
||||
div.my-4
|
||||
> .flex.flex-wrap.items-center.gap-3.not-prose::before {
|
||||
background-color: var(--surface-4);
|
||||
}
|
||||
|
||||
/* "required" badge via ::after on the auth flex row — red pill */
|
||||
/* "required" marker via ::after on the auth flex row */
|
||||
#nd-page:has(.api-page-header) div.my-4 > .flex.flex-wrap.items-center.gap-3.not-prose::after {
|
||||
content: "required";
|
||||
order: 4;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background-color: var(--badge-error-bg);
|
||||
color: var(--badge-error-text);
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.125rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
/* Hide "In: header" text below auth property — redundant with the header badge */
|
||||
#nd-page:has(.api-page-header) div.my-4 .prose-no-margin p:has(> code) {
|
||||
/* Hide the trailing "In: header" line — redundant with the header marker.
|
||||
Matched by position, not shape: descriptions contain a `code` too (status
|
||||
codes), so a bare `p:has(> code)` also hid the API-key description. */
|
||||
#nd-page:has(.api-page-header) div.my-4 .prose-no-margin > p:last-child:has(> code) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -1425,36 +1464,18 @@ html.dark
|
||||
border-color: var(--surface-active);
|
||||
}
|
||||
|
||||
/* Body/Callback section "application/json" label — remove inline code styling */
|
||||
/* Body/Callback "application/json" label — strip inline-code chrome and keep in
|
||||
sync with `.response-section-content-type`; same string, two headers. */
|
||||
#nd-page:has(.api-page-header) .flex.gap-2.items-center.justify-between p.not-prose code.text-xs,
|
||||
#nd-page:has(.api-page-header) .flex.justify-between.gap-2.items-end p.not-prose code.text-xs {
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
color: var(--color-fd-muted-foreground) !important;
|
||||
font-size: 0.875rem !important;
|
||||
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif !important;
|
||||
}
|
||||
|
||||
/* Object/array type triggers in property rows — order 2 + badge chip styling */
|
||||
#nd-page:has(.api-page-header) .flex.flex-wrap.items-center.gap-3.not-prose > button,
|
||||
#nd-page:has(.api-page-header) .flex.flex-wrap.items-center.gap-3.not-prose > span:has(> button) {
|
||||
order: 2;
|
||||
background-color: var(--surface-5);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.125rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
html.dark #nd-page:has(.api-page-header) .flex.flex-wrap.items-center.gap-3.not-prose > button,
|
||||
html.dark
|
||||
#nd-page:has(.api-page-header)
|
||||
.flex.flex-wrap.items-center.gap-3.not-prose
|
||||
> span:has(> button) {
|
||||
background-color: var(--surface-4);
|
||||
color: var(--text-secondary) !important;
|
||||
font-size: var(--text-code) !important;
|
||||
line-height: 1.25rem !important;
|
||||
font-weight: 400 !important;
|
||||
font-family: var(--font-mono-stack) !important;
|
||||
}
|
||||
|
||||
/* Section headings (Authorization, Path Parameters, etc.) — consistent top spacing */
|
||||
@@ -1463,15 +1484,15 @@ html.dark
|
||||
margin-bottom: 0.25rem !important;
|
||||
}
|
||||
|
||||
/* Code examples in right column — wrap long lines instead of horizontal scroll */
|
||||
#nd-page:has(.api-page-header) pre {
|
||||
white-space: pre-wrap !important;
|
||||
word-break: break-all !important;
|
||||
}
|
||||
#nd-page:has(.api-page-header) pre code {
|
||||
width: 100% !important;
|
||||
word-break: break-all !important;
|
||||
overflow-wrap: break-word !important;
|
||||
/* Example-panel code overflows rather than wraps: a wrapped line restarts at
|
||||
column zero and misreports the JSON nesting depth. */
|
||||
|
||||
/* fumadocs' own lucide glyphs (heading anchor, copy button) ship at stroke-width
|
||||
2 while emcn strokes at 1.55, so they read heavier than everything near them.
|
||||
Layout-wide on purpose: one icon weight across the docs. Retired once
|
||||
createAPIPage is given renderHeading/renderCodeBlock. */
|
||||
#nd-docs-layout svg[class*="lucide"] {
|
||||
stroke-width: 1.55;
|
||||
}
|
||||
|
||||
/* Callout/alert — transparent background, no shadow, hide colored bar, add padding */
|
||||
@@ -1497,7 +1518,7 @@ div.not-prose.rounded-md.border.bg-fd-card.p-2 {
|
||||
div.rounded-xl.border.bg-fd-card.shadow-md:has(> [role="none"]) > svg {
|
||||
fill: none !important;
|
||||
color: var(--color-fd-foreground) !important;
|
||||
stroke-width: 1.75 !important;
|
||||
stroke-width: 1.55 !important;
|
||||
flex-shrink: 0;
|
||||
width: 1rem !important;
|
||||
height: 1rem !important;
|
||||
|
||||
+177
-21
@@ -1518,12 +1518,12 @@ export function InputIcon(props: SVGProps<SVGSVGElement>) {
|
||||
export function StartIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
width='26'
|
||||
height='16'
|
||||
viewBox='0 0 26 16'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d='M7.8 13C9.23 13 10.45 12.49 11.47 11.47C12.49 10.45 13 9.23 13 7.8C13 6.37 12.49 5.15 11.47 4.13C10.45 3.11 9.23 2.6 7.8 2.6C6.37 2.6 5.15 3.11 4.13 4.13C3.11 5.15 2.6 6.37 2.6 7.8C2.6 9.23 3.11 10.45 4.13 11.47C5.15 12.49 6.37 13 7.8 13ZM7.8 15.6C5.63 15.6 3.79 14.84 2.28 13.33C0.76 11.81 0 9.97 0 7.8C0 5.63 0.76 3.79 2.28 2.28C3.79 0.76 5.63 0 7.8 0C9.75 0 11.45 0.62 12.89 1.85C14.33 3.09 15.2 4.64 15.5 6.5H24.7C25.07 6.5 25.38 6.62 25.63 6.87C25.88 7.12 26 7.43 26 7.8C26 8.17 25.87 8.48 25.63 8.73C25.38 8.98 25.07 9.1 24.7 9.1H15.5C15.2 10.96 14.33 12.51 12.89 13.75C11.44 14.98 9.75 15.6 7.8 15.6Z'
|
||||
@@ -3898,6 +3898,138 @@ export const ClaudeIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
export function AzureDataExplorerIcon(props: SVGProps<SVGSVGElement>) {
|
||||
const id = useId()
|
||||
const wedgeGradient = `adx_wedge_${id}`
|
||||
const dashLongGradient = `adx_dash_long_${id}`
|
||||
const dashTopGradient = `adx_dash_top_${id}`
|
||||
const dashBottomGradient = `adx_dash_bottom_${id}`
|
||||
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
width='18'
|
||||
height='18'
|
||||
viewBox='0 0 18 18'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M1.47,1.47,16.53,16.53a.57.57,0,0,0,1-.4V1.07A.57.57,0,0,0,16.93.5H1.87A.57.57,0,0,0,1.47,1.47Z'
|
||||
fill={`url(#${wedgeGradient})`}
|
||||
/>
|
||||
<path d='M5.53,5.53l6.94,6.94,5-5V1.07A.57.57,0,0,0,16.93.5H10.56Z' fill='#50e6ff' />
|
||||
<rect
|
||||
x='-0.38'
|
||||
y='12.85'
|
||||
width='9.37'
|
||||
height='1.7'
|
||||
rx='0.27'
|
||||
transform='translate(-8.43 7.06) rotate(-45)'
|
||||
fill={`url(#${dashLongGradient})`}
|
||||
/>
|
||||
<rect
|
||||
x='0.12'
|
||||
y='9.23'
|
||||
width='5.99'
|
||||
height='1.7'
|
||||
rx='0.27'
|
||||
transform='translate(-6.22 5.15) rotate(-45)'
|
||||
fill={`url(#${dashTopGradient})`}
|
||||
/>
|
||||
<rect
|
||||
x='4.89'
|
||||
y='14.04'
|
||||
width='5.99'
|
||||
height='1.7'
|
||||
rx='0.27'
|
||||
transform='translate(-8.22 9.93) rotate(-45)'
|
||||
fill={`url(#${dashBottomGradient})`}
|
||||
/>
|
||||
<rect
|
||||
x='9.19'
|
||||
y='4.75'
|
||||
width='1.7'
|
||||
height='1.7'
|
||||
rx='0.38'
|
||||
transform='translate(-1.02 8.74) rotate(-45)'
|
||||
fill='#fff'
|
||||
/>
|
||||
<rect
|
||||
x='11.59'
|
||||
y='2.35'
|
||||
width='1.7'
|
||||
height='1.7'
|
||||
rx='0.38'
|
||||
transform='translate(1.38 9.73) rotate(-45)'
|
||||
fill='#fff'
|
||||
/>
|
||||
<rect
|
||||
x='11.59'
|
||||
y='7.15'
|
||||
width='1.7'
|
||||
height='1.7'
|
||||
rx='0.38'
|
||||
transform='translate(-2.01 11.14) rotate(-45)'
|
||||
fill='#fff'
|
||||
/>
|
||||
<path
|
||||
d='M13.91,5.33l.66-.66a.38.38,0,0,1,.54,0l.66.66a.38.38,0,0,1,0,.54l-.66.66a.38.38,0,0,1-.54,0l-.66-.66a.38.38,0,0,1,0-.54'
|
||||
fill='#fff'
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={wedgeGradient}
|
||||
x1='1.3'
|
||||
y1='8.6'
|
||||
x2='17.5'
|
||||
y2='8.6'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
>
|
||||
<stop offset='0.1' stopColor='#54aef0' />
|
||||
<stop offset='1' stopColor='#1988d9' />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id={dashLongGradient}
|
||||
x1='0.5'
|
||||
y1='13.7'
|
||||
x2='8.11'
|
||||
y2='13.7'
|
||||
gradientTransform='translate(10.95 0.97) rotate(45)'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
>
|
||||
<stop offset='0' stopColor='#b3b2b3' />
|
||||
<stop offset='1' stopColor='#979797' />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id={dashTopGradient}
|
||||
x1='0.5'
|
||||
y1='10.08'
|
||||
x2='5.72'
|
||||
y2='10.08'
|
||||
gradientTransform='translate(8.04 0.75) rotate(45)'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
>
|
||||
<stop offset='0' stopColor='#b3b2b3' />
|
||||
<stop offset='1' stopColor='#979797' />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id={dashBottomGradient}
|
||||
x1='5.28'
|
||||
y1='14.89'
|
||||
x2='10.49'
|
||||
y2='14.89'
|
||||
gradientTransform='translate(12.84 -1.21) rotate(45)'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
>
|
||||
<stop offset='0' stopColor='#b3b2b3' />
|
||||
<stop offset='1' stopColor='#979797' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function AzureIcon(props: SVGProps<SVGSVGElement>) {
|
||||
const id = useId()
|
||||
const gradient0 = `azure_paint0_${id}`
|
||||
@@ -7406,26 +7538,6 @@ export function BedrockIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function TableIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth={2}
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<rect width='18' height='18' x='3' y='3' rx='2' />
|
||||
<path d='M3 9h18' />
|
||||
<path d='M3 15h18' />
|
||||
<path d='M9 3v18' />
|
||||
<path d='M15 3v18' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
export function ReductoIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
@@ -8578,6 +8690,22 @@ export function HexIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function RabbitmqIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
viewBox='-7.5 0 271 271'
|
||||
preserveAspectRatio='xMidYMid'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M245.44 108.308h-85.09a7.738 7.738 0 0 1-7.735-7.734v-88.68C152.615 5.327 147.29 0 140.726 0h-30.375c-6.568 0-11.89 5.327-11.89 11.894v88.143c0 4.573-3.697 8.29-8.27 8.31l-27.885.133c-4.612.025-8.359-3.717-8.35-8.325l.173-88.241C54.144 5.337 48.817 0 42.24 0H11.89C5.321 0 0 5.327 0 11.894V260.21c0 5.834 4.726 10.56 10.555 10.56H245.44c5.834 0 10.56-4.726 10.56-10.56V118.868c0-5.834-4.726-10.56-10.56-10.56zm-39.902 93.233c0 7.645-6.198 13.844-13.843 13.844H167.69c-7.646 0-13.844-6.199-13.844-13.844v-24.005c0-7.646 6.198-13.844 13.844-13.844h24.005c7.645 0 13.843 6.198 13.843 13.844v24.005z'
|
||||
fill='#F60'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function RailwayIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' fill='currentColor' viewBox='0 0 24 24'>
|
||||
@@ -8625,6 +8753,17 @@ export function NewRelicIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function NetSuiteIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 93.9 59.4' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
fill='#C74634'
|
||||
d='M30.5 59.4H65c16.4-.4 29.3-14.1 28.9-30.4C93.5 13.1 80.7.4 65 0H30.5C14.1-.4.4 12.5 0 28.9s12.5 30 28.9 30.4c.5.1 1 .1 1.6.1m33.7-10.5h-33c-10.6-.3-18.9-9.2-18.6-19.8C13 19 21.1 10.8 31.2 10.5h33c10.6-.3 19.5 8 19.8 18.6s-8 19.5-18.6 19.8z'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function WizaIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 51 49' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
@@ -9035,6 +9174,23 @@ export function LogfireIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function LogRocketIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 19 28' fill='none' role='img' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
fill='currentColor'
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M6.066 3.156A10.53 10.53 0 0 1 9.122 0a10.294 10.294 0 0 1 3.016 3.094 15.59 15.59 0 0 1 2.93 9.777c.637.513 1.293 1.006 1.918 1.53a3.46 3.46 0 0 1 1.104 3.189c-.302 1.457-.592 2.918-.911 4.372a1.214 1.214 0 0 1-1.848.61c-1.027-.825-2.027-1.678-3.05-2.504a4.684 4.684 0 0 1-2.891 1.255 4.678 4.678 0 0 1-3.385-1.22c-.735.541-1.419 1.191-2.138 1.772-.315.31-.666.58-1.046.806a1.215 1.215 0 0 1-1.603-.785c-.329-1.422-.672-2.839-.99-4.263a3.453 3.453 0 0 1 1.163-3.321c.559-.45 1.125-.893 1.694-1.331.159-.08.08-.261.087-.401a15.615 15.615 0 0 1 2.9-9.42m1.007 4.402a2.395 2.395 0 0 0 .21 3.173 2.636 2.636 0 0 0 3.603.075 2.398 2.398 0 0 0 .636-2.634 2.55 2.55 0 0 0-2.14-1.59 2.6 2.6 0 0 0-2.31.974'
|
||||
/>
|
||||
<path
|
||||
fill='currentColor'
|
||||
d='M5.712 23.082a.605.605 0 0 1 .896-.485 5.778 5.778 0 0 0 5.03 0 .61.61 0 0 1 .896.45c.005.89.005 1.78 0 2.67a.602.602 0 0 1-.94.436c-.267-.226-.508-.48-.764-.719-.407.762-.789 1.534-1.199 2.294a.61.61 0 0 1-1.012.006c-.41-.761-.79-1.538-1.206-2.299-.253.24-.494.494-.761.72a.603.603 0 0 1-.94-.442c-.007-.878 0-1.756 0-2.634M9.102 10.259a1.22 1.22 0 0 0 1.248-1.192v-.008a1.221 1.221 0 0 0-1.24-1.2h-.008A1.22 1.22 0 0 0 7.855 9.05v.008a1.22 1.22 0 0 0 1.24 1.2h.007Z'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SmartleadIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// Maps block types to their icon component references
|
||||
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import { Library } from '@sim/emcn/icons'
|
||||
import { Library, Rocket, Table } from '@sim/emcn/icons'
|
||||
import {
|
||||
A2AIcon,
|
||||
AgentMailIcon,
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
AshbyIcon,
|
||||
AthenaIcon,
|
||||
AttioIcon,
|
||||
AzureDataExplorerIcon,
|
||||
AzureIcon,
|
||||
BoxCompanyIcon,
|
||||
BrainIcon,
|
||||
@@ -136,6 +137,7 @@ import {
|
||||
LinkupIcon,
|
||||
LinqIcon,
|
||||
LogfireIcon,
|
||||
LogRocketIcon,
|
||||
LoopsIcon,
|
||||
LumaIcon,
|
||||
MailchimpIcon,
|
||||
@@ -155,6 +157,7 @@ import {
|
||||
MongoDBIcon,
|
||||
MySQLIcon,
|
||||
Neo4jIcon,
|
||||
NetSuiteIcon,
|
||||
NeverBounceIcon,
|
||||
NewRelicIcon,
|
||||
NotionIcon,
|
||||
@@ -180,6 +183,7 @@ import {
|
||||
QdrantIcon,
|
||||
QuartrIcon,
|
||||
QuiverIcon,
|
||||
RabbitmqIcon,
|
||||
RailwayIcon,
|
||||
RB2BIcon,
|
||||
RDSIcon,
|
||||
@@ -205,7 +209,6 @@ import {
|
||||
ServiceNowIcon,
|
||||
SftpIcon,
|
||||
ShopifyIcon,
|
||||
SimDeploymentsIcon,
|
||||
SimilarwebIcon,
|
||||
SimTriggerIcon,
|
||||
SixtyfourIcon,
|
||||
@@ -222,7 +225,6 @@ import {
|
||||
StagehandIcon,
|
||||
StripeIcon,
|
||||
SupabaseIcon,
|
||||
TableIcon,
|
||||
TailscaleIcon,
|
||||
TavilyIcon,
|
||||
TelegramIcon,
|
||||
@@ -279,6 +281,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
ashby: AshbyIcon,
|
||||
athena: AthenaIcon,
|
||||
attio: AttioIcon,
|
||||
azure_data_explorer: AzureDataExplorerIcon,
|
||||
azure_devops: AzureIcon,
|
||||
box: BoxCompanyIcon,
|
||||
brandfetch: BrandfetchIcon,
|
||||
@@ -309,7 +312,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
datadog: DatadogIcon,
|
||||
datagma: DatagmaIcon,
|
||||
daytona: DaytonaIcon,
|
||||
deployments: SimDeploymentsIcon,
|
||||
deployments: Rocket,
|
||||
devin: DevinIcon,
|
||||
discord: DiscordIcon,
|
||||
docusign: DocuSignIcon,
|
||||
@@ -414,6 +417,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
linkup: LinkupIcon,
|
||||
linq: LinqIcon,
|
||||
logfire: LogfireIcon,
|
||||
logrocket: LogRocketIcon,
|
||||
logs: Library,
|
||||
logs_v2: Library,
|
||||
loops: LoopsIcon,
|
||||
@@ -439,6 +443,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
mongodb: MongoDBIcon,
|
||||
mysql: MySQLIcon,
|
||||
neo4j: Neo4jIcon,
|
||||
netsuite: NetSuiteIcon,
|
||||
neverbounce: NeverBounceIcon,
|
||||
new_relic: NewRelicIcon,
|
||||
notion: NotionIcon,
|
||||
@@ -466,6 +471,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
qdrant: QdrantIcon,
|
||||
quartr: QuartrIcon,
|
||||
quiver: QuiverIcon,
|
||||
rabbitmq: RabbitmqIcon,
|
||||
railway: RailwayIcon,
|
||||
rb2b: RB2BIcon,
|
||||
rds: RDSIcon,
|
||||
@@ -511,7 +517,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
stt: STTIcon,
|
||||
stt_v2: STTIcon,
|
||||
supabase: SupabaseIcon,
|
||||
table: TableIcon,
|
||||
table: Table,
|
||||
tailscale: TailscaleIcon,
|
||||
tavily: TavilyIcon,
|
||||
telegram: TelegramIcon,
|
||||
|
||||
@@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum
|
||||
| `404` | Resource not found | Verify the ID exists and belongs to your workspace |
|
||||
| `429` | Rate limit exceeded | Wait for the duration in the `Retry-After` header |
|
||||
|
||||
### Unrecognized fields are rejected
|
||||
|
||||
Every v2 endpoint validates the request against its published schema — path parameters, query string, and body — and answers `400` for any field it does not declare. A misspelled parameter is an error rather than a silent no-op, so `?limt=20` fails instead of quietly returning an unbounded list.
|
||||
|
||||
This holds for endpoints that declare no query parameters at all. Do not append tracking tags, cache busters, or other extra parameters to a v2 URL; send only what the endpoint documents.
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "BAD_REQUEST",
|
||||
"message": "Invalid request",
|
||||
"details": [
|
||||
{ "code": "unrecognized_keys", "keys": ["limt"], "path": [], "message": "Unrecognized key: \"limt\"" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
Use [Get Billing Status](/api-reference/billing/getBillingStatus) to inspect current credit and storage usage.
|
||||
</Callout>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"pages": ["executeWorkflow", "getWorkflowExecution", "cancelExecution", "getJobStatus"]
|
||||
}
|
||||
@@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum
|
||||
| `404` | Resource not found | Verify the ID exists and belongs to your workspace |
|
||||
| `429` | Rate limit exceeded | Wait for the duration in the `Retry-After` header |
|
||||
|
||||
### Unrecognized fields are rejected
|
||||
|
||||
Every v2 endpoint validates the request against its published schema — path parameters, query string, and body — and answers `400` for any field it does not declare. A misspelled parameter is an error rather than a silent no-op, so `?limt=20` fails instead of quietly returning an unbounded list.
|
||||
|
||||
This holds for endpoints that declare no query parameters at all. Do not append tracking tags, cache busters, or other extra parameters to a v2 URL; send only what the endpoint documents.
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "BAD_REQUEST",
|
||||
"message": "Invalid request",
|
||||
"details": [
|
||||
{ "code": "unrecognized_keys", "keys": ["limt"], "path": [], "message": "Unrecognized key: \"limt\"" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
Use [Get Billing Status](/api-reference/billing/getBillingStatus) to inspect current credit and storage usage.
|
||||
</Callout>
|
||||
|
||||
@@ -21,16 +21,51 @@ With Ashby, you can:
|
||||
- **Add notes to candidates**: Attach notes to candidate records to capture feedback, context, or follow-up items
|
||||
- **List and view jobs**: Browse all open, closed, and archived job postings with location and department info
|
||||
- **List applications**: View all applications across your organization with candidate and job details, status tracking, and pagination
|
||||
- **Sync jobs incrementally**: Pass the sync token from a previous List Jobs run to fetch only the reqs that changed, instead of rescanning every req on each run
|
||||
- **Annotate jobs and reqs**: Set custom field values on a job, application, candidate, or opening, one field at a time or several at once
|
||||
- **Delete and anonymize**: Remove an application, or strip personal information from a candidate
|
||||
|
||||
The Ashby block also supports **webhook triggers** that automatically start workflows in response to Ashby events. Available triggers include Application Submitted, Candidate Stage Change, Candidate Hired, Candidate Deleted, Job Created, and Offer Created. Webhooks are fully managed — Sim automatically creates the webhook in Ashby when you save the trigger and deletes it when you remove it, so there's no manual webhook configuration needed. Just provide your Ashby API key (with `apiKeysWrite` permission) and select the event type.
|
||||
|
||||
In Sim, the Ashby integration enables your agents to programmatically manage your recruiting pipeline. Agents can search for candidates, create new candidate records, add notes after interviews, and monitor applications across jobs. This allows you to automate recruiting workflows like candidate intake, interview follow-ups, pipeline reporting, and cross-referencing candidates across roles.
|
||||
|
||||
### API key permissions
|
||||
|
||||
Ashby grants permissions per module rather than per endpoint, and a missing permission fails at **runtime**, not when you build the workflow.
|
||||
A key without the right scope returns a 403 in the middle of a run, so check the key before scheduling anything against it.
|
||||
|
||||
| Permission | Covers |
|
||||
| --- | --- |
|
||||
| `jobsRead` | List Jobs, Get Job, List Job Postings, Get Job Posting |
|
||||
| `candidatesRead` | Application and candidate reads |
|
||||
| `candidatesWrite` | Create Candidate, Create Application, Set Custom Field Value, Set Custom Field Values, Change Application Source, Anonymize Candidate |
|
||||
| `candidatesDelete` | Delete Application. This is separate from `candidatesWrite` - a read and write key returns 403 here |
|
||||
| `apiKeysWrite` | Managed webhook triggers |
|
||||
|
||||
`candidatesDelete` is a module permission, not an endpoint one, and Delete Application sits under the Candidates module.
|
||||
|
||||
Two more settings on the API key are checkboxes rather than module scopes, and both are easy to miss:
|
||||
|
||||
- **Allow access to confidential jobs and projects.** Without it, confidential reqs and the candidates on them are invisible to the key entirely - they do not appear in List Jobs at all, rather than appearing with fields hidden.
|
||||
- **Allow access to non-offer private fields.** This gates custom fields marked private outside of offers.
|
||||
|
||||
Every job returned by List Jobs carries a `confidential` flag, so you can filter confidential reqs out at sync time before they reach a prompt or a downstream surface.
|
||||
|
||||
### Ashby API limitations
|
||||
|
||||
These are constraints of the Ashby API itself, not of the Sim block:
|
||||
|
||||
- **No notes or tags on a job or req.** Both are candidate-scoped in Ashby. Custom field values are the only way to annotate a job, which is what Set Custom Field Value is for. Writing `null` clears a value, so the annotation is reversible.
|
||||
- **No pagination on List Job Postings.** The endpoint returns every posting in one response, with no cursor, page limit, or sync token. Paginate through List Jobs instead when you need to page.
|
||||
- **No way to delete, archive, or update a custom field definition.** Creating one is irreversible, so never create throwaway field definitions against a shared organization.
|
||||
- **No note deletion endpoint.** Notes can be created and listed but never removed.
|
||||
- **No candidate deletion endpoint.** Anonymize Candidate strips personal information but leaves the record and its applications in place. Real deletion is available only in the Ashby UI, is limited to a 10-day window, and is restricted to certain roles.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Ashby into the workflow. Manage candidates (list, get, create, update, search, tag), applications (list, get, create, change stage), jobs (list, get), job postings (list, get), offers (list, get), notes (list, create), interviews (list), and reference data (sources, tags, archive reasons, custom fields, departments, locations, openings, users).
|
||||
Integrate Ashby into the workflow. Manage candidates (list, get, create, update, search, tag, anonymize), applications (list, get, create, delete, change stage, change source), jobs (list, get), job postings (list, get), offers (list, get), notes (list, create), interviews (list), custom field values (set one or many), and reference data (sources, tags, archive reasons, custom fields, departments, locations, openings, users).
|
||||
|
||||
|
||||
|
||||
@@ -59,7 +94,8 @@ Adds a tag to a candidate in Ashby and returns the updated candidate.
|
||||
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
|
||||
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
|
||||
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
|
||||
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
|
||||
| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) |
|
||||
| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) |
|
||||
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
|
||||
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
|
||||
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
|
||||
@@ -80,9 +116,113 @@ Adds a tag to a candidate in Ashby and returns the updated candidate.
|
||||
| `author` | json | Note author \(id, firstName, lastName, email\) |
|
||||
| `isPrivate` | boolean | Whether the note is private |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `applicationId` | string | UUID of the deleted application |
|
||||
| `moreDataAvailable` | boolean | Whether more pages exist |
|
||||
| `nextCursor` | string | Pagination cursor for next page |
|
||||
| `syncToken` | string | Sync token for incremental updates |
|
||||
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output |
|
||||
|
||||
### Ashby Anonymize Candidate
|
||||
|
||||
Strips personally identifiable information from a candidate in Ashby. This does not delete the candidate - the record and its applications remain, with the PII removed. Ashby exposes no candidate deletion endpoint; true deletion is UI-only, restricted by role, and limited to a 10-day window. Requires the candidatesWrite permission.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Ashby API Key |
|
||||
| `candidateId` | string | Yes | UUID of the candidate to anonymize |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) |
|
||||
| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion\) |
|
||||
| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) |
|
||||
| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) |
|
||||
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
|
||||
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
|
||||
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
|
||||
| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) |
|
||||
| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) |
|
||||
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
|
||||
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
|
||||
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
|
||||
| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) |
|
||||
| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt\) |
|
||||
| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) |
|
||||
| `tags` | json | List of candidate tags \(id, title, isArchived\) |
|
||||
| `id` | string | Resource UUID |
|
||||
| `name` | string | Resource name |
|
||||
| `title` | string | Job title or job posting title |
|
||||
| `status` | string | Status |
|
||||
| `candidate` | json | Candidate summary \(id, name, primaryEmailAddress, primaryPhoneNumber\). For full candidate fields use the candidates list output or the get/create/update candidate operations. |
|
||||
| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], createdAt, updatedAt\) |
|
||||
| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) |
|
||||
| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) |
|
||||
| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt, job \[included when expandJob=true\]\) |
|
||||
| `content` | string | Note content |
|
||||
| `author` | json | Note author \(id, firstName, lastName, email\) |
|
||||
| `isPrivate` | boolean | Whether the note is private |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `applicationId` | string | UUID of the deleted application |
|
||||
| `moreDataAvailable` | boolean | Whether more pages exist |
|
||||
| `nextCursor` | string | Pagination cursor for next page |
|
||||
| `syncToken` | string | Sync token for incremental updates |
|
||||
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output |
|
||||
|
||||
### Ashby Change Application Source
|
||||
|
||||
Changes the source attributed to an existing application, so programmatically created applications report correctly on the recruiting side. Requires the candidatesWrite permission.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Ashby API Key |
|
||||
| `applicationId` | string | Yes | UUID of the application whose source should change |
|
||||
| `sourceId` | string | No | UUID of the source to attribute the application to, as returned by List Sources. Omit only when unsetSource is true. |
|
||||
| `unsetSource` | boolean | No | Set true to deliberately clear the application source. Required to unset, so that a missing or empty sourceId cannot wipe attribution by accident. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) |
|
||||
| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion\) |
|
||||
| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) |
|
||||
| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) |
|
||||
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
|
||||
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
|
||||
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
|
||||
| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) |
|
||||
| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) |
|
||||
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
|
||||
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
|
||||
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
|
||||
| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) |
|
||||
| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt\) |
|
||||
| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) |
|
||||
| `tags` | json | List of candidate tags \(id, title, isArchived\) |
|
||||
| `id` | string | Resource UUID |
|
||||
| `name` | string | Resource name |
|
||||
| `title` | string | Job title or job posting title |
|
||||
| `status` | string | Status |
|
||||
| `candidate` | json | Candidate summary \(id, name, primaryEmailAddress, primaryPhoneNumber\). For full candidate fields use the candidates list output or the get/create/update candidate operations. |
|
||||
| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], createdAt, updatedAt\) |
|
||||
| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) |
|
||||
| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) |
|
||||
| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt, job \[included when expandJob=true\]\) |
|
||||
| `content` | string | Note content |
|
||||
| `author` | json | Note author \(id, firstName, lastName, email\) |
|
||||
| `isPrivate` | boolean | Whether the note is private |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `applicationId` | string | UUID of the deleted application |
|
||||
| `moreDataAvailable` | boolean | Whether more pages exist |
|
||||
| `nextCursor` | string | Pagination cursor for next page |
|
||||
| `syncToken` | string | Sync token for incremental updates |
|
||||
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output |
|
||||
|
||||
### Ashby Change Application Stage
|
||||
|
||||
@@ -108,7 +248,8 @@ Moves an application to a different interview stage. Requires an archive reason
|
||||
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
|
||||
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
|
||||
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
|
||||
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
|
||||
| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) |
|
||||
| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) |
|
||||
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
|
||||
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
|
||||
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
|
||||
@@ -129,9 +270,11 @@ Moves an application to a different interview stage. Requires an archive reason
|
||||
| `author` | json | Note author \(id, firstName, lastName, email\) |
|
||||
| `isPrivate` | boolean | Whether the note is private |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `applicationId` | string | UUID of the deleted application |
|
||||
| `moreDataAvailable` | boolean | Whether more pages exist |
|
||||
| `nextCursor` | string | Pagination cursor for next page |
|
||||
| `syncToken` | string | Sync token for incremental updates |
|
||||
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output |
|
||||
|
||||
### Ashby Create Application
|
||||
|
||||
@@ -161,7 +304,8 @@ Creates a new application for a candidate on a job. Optionally specify interview
|
||||
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
|
||||
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
|
||||
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
|
||||
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
|
||||
| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) |
|
||||
| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) |
|
||||
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
|
||||
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
|
||||
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
|
||||
@@ -182,9 +326,11 @@ Creates a new application for a candidate on a job. Optionally specify interview
|
||||
| `author` | json | Note author \(id, firstName, lastName, email\) |
|
||||
| `isPrivate` | boolean | Whether the note is private |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `applicationId` | string | UUID of the deleted application |
|
||||
| `moreDataAvailable` | boolean | Whether more pages exist |
|
||||
| `nextCursor` | string | Pagination cursor for next page |
|
||||
| `syncToken` | string | Sync token for incremental updates |
|
||||
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output |
|
||||
|
||||
### Ashby Create Candidate
|
||||
|
||||
@@ -217,7 +363,8 @@ Creates a new candidate record in Ashby.
|
||||
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
|
||||
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
|
||||
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
|
||||
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
|
||||
| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) |
|
||||
| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) |
|
||||
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
|
||||
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
|
||||
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
|
||||
@@ -238,9 +385,11 @@ Creates a new candidate record in Ashby.
|
||||
| `author` | json | Note author \(id, firstName, lastName, email\) |
|
||||
| `isPrivate` | boolean | Whether the note is private |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `applicationId` | string | UUID of the deleted application |
|
||||
| `moreDataAvailable` | boolean | Whether more pages exist |
|
||||
| `nextCursor` | string | Pagination cursor for next page |
|
||||
| `syncToken` | string | Sync token for incremental updates |
|
||||
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output |
|
||||
|
||||
### Ashby Create Note
|
||||
|
||||
@@ -272,6 +421,23 @@ Creates a note on a candidate in Ashby. Supports plain text and HTML content (bo
|
||||
| ↳ `lastName` | string | Author last name |
|
||||
| ↳ `email` | string | Author email |
|
||||
|
||||
### Ashby Delete Application
|
||||
|
||||
Permanently deletes an application in Ashby. Requires the candidatesDelete permission, which is a separate module permission from candidatesWrite - a read and write key returns 403 here. There is no equivalent endpoint for deleting a candidate; candidate deletion is UI-only.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Ashby API Key |
|
||||
| `applicationId` | string | Yes | UUID of the application to delete |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `applicationId` | string | UUID of the deleted application |
|
||||
|
||||
### Ashby Get Application
|
||||
|
||||
Retrieves full details about a single application by its ID.
|
||||
@@ -294,7 +460,8 @@ Retrieves full details about a single application by its ID.
|
||||
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
|
||||
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
|
||||
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
|
||||
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
|
||||
| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) |
|
||||
| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) |
|
||||
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
|
||||
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
|
||||
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
|
||||
@@ -315,9 +482,11 @@ Retrieves full details about a single application by its ID.
|
||||
| `author` | json | Note author \(id, firstName, lastName, email\) |
|
||||
| `isPrivate` | boolean | Whether the note is private |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `applicationId` | string | UUID of the deleted application |
|
||||
| `moreDataAvailable` | boolean | Whether more pages exist |
|
||||
| `nextCursor` | string | Pagination cursor for next page |
|
||||
| `syncToken` | string | Sync token for incremental updates |
|
||||
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output |
|
||||
|
||||
### Ashby Get Candidate
|
||||
|
||||
@@ -341,7 +510,8 @@ Retrieves full details about a single candidate by their ID.
|
||||
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
|
||||
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
|
||||
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
|
||||
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
|
||||
| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) |
|
||||
| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) |
|
||||
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
|
||||
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
|
||||
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
|
||||
@@ -362,9 +532,11 @@ Retrieves full details about a single candidate by their ID.
|
||||
| `author` | json | Note author \(id, firstName, lastName, email\) |
|
||||
| `isPrivate` | boolean | Whether the note is private |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `applicationId` | string | UUID of the deleted application |
|
||||
| `moreDataAvailable` | boolean | Whether more pages exist |
|
||||
| `nextCursor` | string | Pagination cursor for next page |
|
||||
| `syncToken` | string | Sync token for incremental updates |
|
||||
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output |
|
||||
|
||||
### Ashby Get Job
|
||||
|
||||
@@ -388,7 +560,8 @@ Retrieves full details about a single job by its ID.
|
||||
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
|
||||
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
|
||||
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
|
||||
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
|
||||
| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) |
|
||||
| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) |
|
||||
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
|
||||
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
|
||||
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
|
||||
@@ -409,9 +582,11 @@ Retrieves full details about a single job by its ID.
|
||||
| `author` | json | Note author \(id, firstName, lastName, email\) |
|
||||
| `isPrivate` | boolean | Whether the note is private |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `applicationId` | string | UUID of the deleted application |
|
||||
| `moreDataAvailable` | boolean | Whether more pages exist |
|
||||
| `nextCursor` | string | Pagination cursor for next page |
|
||||
| `syncToken` | string | Sync token for incremental updates |
|
||||
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output |
|
||||
|
||||
### Ashby Get Job Posting
|
||||
|
||||
@@ -506,7 +681,8 @@ Retrieves full details about a single offer by its ID.
|
||||
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
|
||||
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
|
||||
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
|
||||
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
|
||||
| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) |
|
||||
| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) |
|
||||
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
|
||||
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
|
||||
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
|
||||
@@ -527,9 +703,11 @@ Retrieves full details about a single offer by its ID.
|
||||
| `author` | json | Note author \(id, firstName, lastName, email\) |
|
||||
| `isPrivate` | boolean | Whether the note is private |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `applicationId` | string | UUID of the deleted application |
|
||||
| `moreDataAvailable` | boolean | Whether more pages exist |
|
||||
| `nextCursor` | string | Pagination cursor for next page |
|
||||
| `syncToken` | string | Sync token for incremental updates |
|
||||
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output |
|
||||
|
||||
### Ashby List Applications
|
||||
|
||||
@@ -741,6 +919,7 @@ Lists all job postings in Ashby.
|
||||
| `location` | string | No | Filter by location name \(case sensitive\) |
|
||||
| `department` | string | No | Filter by department name \(case sensitive\) |
|
||||
| `listedOnly` | boolean | No | When true, only returns listed \(publicly visible\) job postings \(default false\) |
|
||||
| `includeUnpublishedJobPostings` | boolean | No | When true, also returns unpublished \(Draft\) job postings. The endpoint already returns both listed and unlisted published postings by default, so this only adds drafts. |
|
||||
| `jobBoardId` | string | No | UUID of a specific job board to filter postings to. If omitted, returns postings on the primary external job board. |
|
||||
|
||||
#### Output
|
||||
@@ -759,6 +938,7 @@ Lists all job postings in Ashby.
|
||||
| ↳ `secondaryLocationIds` | array | Secondary location UUIDs |
|
||||
| ↳ `workplaceType` | string | Workplace type \(OnSite, Remote, Hybrid\) |
|
||||
| ↳ `employmentType` | string | Employment type \(FullTime, PartTime, Intern, Contract, Temporary\) |
|
||||
| ↳ `status` | string | Posting status \(Draft or Published\) |
|
||||
| ↳ `isListed` | boolean | Whether the posting is publicly listed |
|
||||
| ↳ `publishedDate` | string | ISO 8601 published date |
|
||||
| ↳ `applicationDeadline` | string | ISO 8601 application deadline |
|
||||
@@ -778,7 +958,8 @@ Lists all jobs in an Ashby organization. By default returns Open, Closed, and Ar
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Ashby API Key |
|
||||
| `cursor` | string | No | Opaque pagination cursor from a previous response nextCursor value |
|
||||
| `perPage` | number | No | Number of results per page \(default 100\) |
|
||||
| `perPage` | number | No | Number of results per page \(default and max 100\). Ashby silently caps larger values rather than erroring. |
|
||||
| `syncToken` | string | No | Opaque token from a prior sync to fetch only jobs changed since then. Ashby only returns a new syncToken on the last page, so drain moreDataAvailable/nextCursor before persisting it. |
|
||||
| `status` | string | No | Filter by job status: Open, Closed, Archived, or Draft |
|
||||
| `createdAfter` | string | No | Only return jobs created after this ISO 8601 timestamp \(e.g. 2024-01-01T00:00:00Z\) |
|
||||
| `openedAfter` | string | No | Only return jobs opened after this ISO 8601 timestamp |
|
||||
@@ -793,6 +974,7 @@ Lists all jobs in an Ashby organization. By default returns Open, Closed, and Ar
|
||||
| `jobs` | array | List of jobs |
|
||||
| `moreDataAvailable` | boolean | Whether more pages of results exist |
|
||||
| `nextCursor` | string | Opaque cursor for fetching the next page |
|
||||
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental run, returned only once the last page is drained. Named as a cursor because that is what it is - an opaque resumption marker, not a credential - so it stays readable in block output alongside nextCursor. |
|
||||
|
||||
### Ashby List Locations
|
||||
|
||||
@@ -974,7 +1156,8 @@ Removes a tag from a candidate in Ashby and returns the updated candidate.
|
||||
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
|
||||
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
|
||||
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
|
||||
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
|
||||
| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) |
|
||||
| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) |
|
||||
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
|
||||
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
|
||||
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
|
||||
@@ -995,9 +1178,11 @@ Removes a tag from a candidate in Ashby and returns the updated candidate.
|
||||
| `author` | json | Note author \(id, firstName, lastName, email\) |
|
||||
| `isPrivate` | boolean | Whether the note is private |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `applicationId` | string | UUID of the deleted application |
|
||||
| `moreDataAvailable` | boolean | Whether more pages exist |
|
||||
| `nextCursor` | string | Pagination cursor for next page |
|
||||
| `syncToken` | string | Sync token for incremental updates |
|
||||
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output |
|
||||
|
||||
### Ashby Search Candidates
|
||||
|
||||
@@ -1017,6 +1202,78 @@ Searches for candidates by name and/or email with AND logic. Results are limited
|
||||
| --------- | ---- | ----------- |
|
||||
| `candidates` | array | Matching candidates \(max 100 results\) |
|
||||
|
||||
### Ashby Set Custom Field Value
|
||||
|
||||
Sets the value of a single custom field on an Ashby Application, Candidate, Job, or Opening. Custom fields are the only way to annotate a job or req, since Ashby has no job notes and no job tags. Requires the candidatesWrite permission.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Ashby API Key |
|
||||
| `objectId` | string | Yes | UUID of the object to set the field on \(application, candidate, job, or opening\) |
|
||||
| `objectType` | string | Yes | Type of the object: Application, Candidate, Job, or Opening |
|
||||
| `fieldId` | string | Yes | UUID of the custom field definition to set, as returned by List Custom Fields. This is the field definition ID, not the ID of a value already on the object. |
|
||||
| `fieldValue` | json | No | Value to write, matching the field type: boolean, number, string \(String, LongText, Date, Url, or a ValueSelect option\), string array \(MultiValueSelect\), or an object for Currency \(\{value, currencyCode\}\), NumberRange \(\{type, minValue, maxValue\}\), CompensationRange, and Location \(\{country, region, city\}\). Pass null to clear the value, which makes the annotation reversible. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `customField` | object | The custom field as stored on the object after the write |
|
||||
|
||||
### Ashby Set Custom Field Values
|
||||
|
||||
Sets several custom field values on one Ashby Application, Candidate, Job, or Opening in a single call. Prefer this over repeated single-field writes to the same object - Ashby recommends it because concurrent single-field calls can race and overwrite each other. Requires the candidatesWrite permission.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Ashby API Key |
|
||||
| `objectId` | string | Yes | UUID of the object to set the fields on \(application, candidate, job, or opening\) |
|
||||
| `objectType` | string | Yes | Type of the object: Application, Candidate, Job, or Opening |
|
||||
| `values` | json | Yes | Array of at least one \{ fieldId, fieldValue \} pair. fieldId is a custom field definition UUID from List Custom Fields. fieldValue matches the field type: boolean, number, string, string array \(MultiValueSelect\), or an object for Currency, NumberRange, CompensationRange, and Location. Pass null as a fieldValue to clear that field. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) |
|
||||
| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion\) |
|
||||
| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) |
|
||||
| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) |
|
||||
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
|
||||
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
|
||||
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
|
||||
| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) |
|
||||
| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) |
|
||||
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
|
||||
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
|
||||
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
|
||||
| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) |
|
||||
| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt\) |
|
||||
| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) |
|
||||
| `tags` | json | List of candidate tags \(id, title, isArchived\) |
|
||||
| `id` | string | Resource UUID |
|
||||
| `name` | string | Resource name |
|
||||
| `title` | string | Job title or job posting title |
|
||||
| `status` | string | Status |
|
||||
| `candidate` | json | Candidate summary \(id, name, primaryEmailAddress, primaryPhoneNumber\). For full candidate fields use the candidates list output or the get/create/update candidate operations. |
|
||||
| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], createdAt, updatedAt\) |
|
||||
| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) |
|
||||
| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) |
|
||||
| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt, job \[included when expandJob=true\]\) |
|
||||
| `content` | string | Note content |
|
||||
| `author` | json | Note author \(id, firstName, lastName, email\) |
|
||||
| `isPrivate` | boolean | Whether the note is private |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `applicationId` | string | UUID of the deleted application |
|
||||
| `moreDataAvailable` | boolean | Whether more pages exist |
|
||||
| `nextCursor` | string | Pagination cursor for next page |
|
||||
| `syncToken` | string | Sync token for incremental updates |
|
||||
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output |
|
||||
|
||||
### Ashby Update Candidate
|
||||
|
||||
Updates an existing candidate record in Ashby. Only provided fields are changed.
|
||||
@@ -1051,7 +1308,8 @@ Updates an existing candidate record in Ashby. Only provided fields are changed.
|
||||
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
|
||||
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
|
||||
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
|
||||
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
|
||||
| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) |
|
||||
| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) |
|
||||
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
|
||||
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
|
||||
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
|
||||
@@ -1072,9 +1330,11 @@ Updates an existing candidate record in Ashby. Only provided fields are changed.
|
||||
| `author` | json | Note author \(id, firstName, lastName, email\) |
|
||||
| `isPrivate` | boolean | Whether the note is private |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `applicationId` | string | UUID of the deleted application |
|
||||
| `moreDataAvailable` | boolean | Whether more pages exist |
|
||||
| `nextCursor` | string | Pagination cursor for next page |
|
||||
| `syncToken` | string | Sync token for incremental updates |
|
||||
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output |
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
---
|
||||
title: Azure Data Explorer
|
||||
description: Query and manage Azure Data Explorer (Kusto) clusters with KQL
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="azure_data_explorer"
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Azure Data Explorer](https://azure.microsoft.com/products/data-explorer) is Microsoft's analytics service for very large volumes of machine-generated data — logs, metrics, traces, telemetry, and IoT events. It is built for questions asked over billions of rows: you write a query, and it comes back in seconds. The same engine powers Fabric Eventhouse, Azure Monitor, and Application Insights.
|
||||
|
||||
You query it with **KQL** (Kusto Query Language), a pipeline language that reads left to right. Start with a table, then pipe the rows through operators:
|
||||
|
||||
```kusto
|
||||
StormEvents
|
||||
| where StartTime > ago(7d) and State == "FLORIDA"
|
||||
| summarize Events = count() by EventType
|
||||
| top 10 by Events
|
||||
```
|
||||
|
||||
Azure Data Explorer also has a second command family: **management commands**, which all start with a dot (`.show tables`, `.create table`, `.ingest inline`). Queries read data; management commands inspect and change the cluster itself.
|
||||
|
||||
In Sim, this integration gives your agents both halves:
|
||||
|
||||
- **Ask questions of your telemetry** — turn a plain-English question into KQL, run it, and answer with real numbers instead of a guess
|
||||
- **Discover the data model first** — list databases, tables, and stored functions, read a table's schema, and check its size and row count, so a generated query references columns that actually exist and you know what it will scan
|
||||
- **Push rows in** — send small batches straight into a table, or materialize a query result into a rollup table with `.set-or-append`
|
||||
- **Manage tables** — create a table from a column schema, or drop one you no longer need
|
||||
- **Debug the pipeline** — list ingestion failures with their error codes and root causes, and check the state of a long-running operation
|
||||
- **Run any management command** — the escape hatch for policies, mappings, and anything else on the control plane
|
||||
|
||||
Authentication uses a **Microsoft Entra service principal** (an app registration with a tenant ID, client ID, and client secret) rather than an interactive sign-in, so scheduled and unattended workflows keep working without anyone logging in. Grant that principal access to the database with `.add database <DATABASE> viewers ('aadapp=<clientId>;<tenantId>')` — use `viewers` for read-only agents, and `ingestors` or `users` only when a workflow needs to write.
|
||||
|
||||
A few things worth knowing before you build:
|
||||
|
||||
- **Enable Read-only on the Run Query operation** whenever an agent writes its own KQL. It sends the `x-ms-readonly` header, and the cluster then refuses anything that would change data — a cheap guardrail against a generated query doing more than you intended.
|
||||
- **Results are capped at 10,000 rows.** Every result reports `rowCount`, `totalRowCount`, and `truncated`, so a query that returned more than the cap says so rather than quietly looking complete. Aggregate with `summarize` or bound the query with `take` instead of pulling raw rows.
|
||||
- **Ingest Rows Inline is for small batches.** It is ideal for tens or hundreds of rows from a workflow run. For continuous or high-volume loading, use Azure Data Explorer's queued or streaming ingestion instead.
|
||||
- **Ingest From Query defaults to `set-or-append`**, which adds to an existing table. `set-or-replace` discards everything already in the target table — pick it only when you mean to rebuild the rollup from scratch. For a large backfill, turn on the background option and poll Show Operations with the operation ID it returns.
|
||||
- **Ingest From Query matches columns by position, not by name.** Kusto aligns the query result to the target table on column type and order, so a query that projects the right columns in the wrong order ingests data into the wrong columns without erroring. End the query with an explicit `project` in the table's column order, and confirm with Show Table Schema first.
|
||||
- **Drop Table is permanent.** It deletes the table and its data. Give an agent the `viewers` role rather than `admins` unless a workflow genuinely needs to change schema.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Run Kusto Query Language queries against Azure Data Explorer and Fabric Eventhouse clusters, discover databases, tables, and schemas, push small batches of rows inline, and run management commands. Authenticates with a Microsoft Entra service principal using client credentials, so no interactive sign-in is needed.
|
||||
|
||||
|
||||
|
||||
## Actions
|
||||
|
||||
### Azure Data Explorer Query
|
||||
|
||||
Run a Kusto Query Language (KQL) query against an Azure Data Explorer database and return the primary result table.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
|
||||
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
|
||||
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
|
||||
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
|
||||
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
|
||||
| `database` | string | Yes | Database to run the query against |
|
||||
| `query` | string | Yes | KQL query text \(e.g., StormEvents \| where State == "FLORIDA" \| summarize count\(\) by EventType\) |
|
||||
| `properties` | json | No | Kusto request properties object, e.g. \{"Options":\{"servertimeout":"00:04:00","queryconsistency":"strongconsistency"\}\} |
|
||||
| `readOnly` | boolean | No | Send x-ms-readonly so the cluster rejects any request that would change data |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tableName` | string | Name Kusto assigned to the returned result table |
|
||||
| `columns` | array | Column metadata for the result table |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Kusto scalar type |
|
||||
| ↳ `dataType` | string | Approximate .NET type |
|
||||
| `rows` | array | Result rows as positional arrays matching the columns order |
|
||||
| `records` | array | Result rows keyed by column name |
|
||||
| `rowCount` | number | Rows carried in this result, after the row cap |
|
||||
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
|
||||
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
|
||||
|
||||
### Azure Data Explorer Management Command
|
||||
|
||||
Run an Azure Data Explorer management command (a control command starting with ".") such as .show, .create, .alter, or .drop. Write commands change cluster state permanently; use the Query operation for reads.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
|
||||
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
|
||||
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
|
||||
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
|
||||
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
|
||||
| `command` | string | Yes | Management command text, starting with "." \(e.g., .show table Events details\) |
|
||||
| `database` | string | No | Database context for the command. Required for all commands except cluster-level ones such as .show databases |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tableName` | string | Name Kusto assigned to the returned result table |
|
||||
| `columns` | array | Column metadata for the result table |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Kusto scalar type |
|
||||
| ↳ `dataType` | string | Approximate .NET type |
|
||||
| `rows` | array | Result rows as positional arrays matching the columns order |
|
||||
| `records` | array | Result rows keyed by column name |
|
||||
| `rowCount` | number | Rows carried in this result, after the row cap |
|
||||
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
|
||||
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
|
||||
|
||||
### Azure Data Explorer List Databases
|
||||
|
||||
List the databases on an Azure Data Explorer cluster that the service principal can access.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
|
||||
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
|
||||
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
|
||||
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
|
||||
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tableName` | string | Name Kusto assigned to the returned result table |
|
||||
| `columns` | array | Column metadata for the result table |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Kusto scalar type |
|
||||
| ↳ `dataType` | string | Approximate .NET type |
|
||||
| `rows` | array | Result rows as positional arrays matching the columns order |
|
||||
| `records` | array | Result rows keyed by column name |
|
||||
| `rowCount` | number | Rows carried in this result, after the row cap |
|
||||
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
|
||||
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
|
||||
| `databases` | array | Database names, read from the DatabaseName column |
|
||||
|
||||
### Azure Data Explorer List Tables
|
||||
|
||||
List the tables in an Azure Data Explorer database, with their folder and docstring.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
|
||||
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
|
||||
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
|
||||
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
|
||||
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
|
||||
| `database` | string | Yes | Database whose tables should be listed |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tableName` | string | Name Kusto assigned to the returned result table |
|
||||
| `columns` | array | Column metadata for the result table |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Kusto scalar type |
|
||||
| ↳ `dataType` | string | Approximate .NET type |
|
||||
| `rows` | array | Result rows as positional arrays matching the columns order |
|
||||
| `records` | array | Result rows keyed by column name |
|
||||
| `rowCount` | number | Rows carried in this result, after the row cap |
|
||||
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
|
||||
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
|
||||
| `tables` | array | Table names, read from the TableName column |
|
||||
|
||||
### Azure Data Explorer Show Table Schema
|
||||
|
||||
Read the column schema of an Azure Data Explorer table in CSL form (e.g., "Timestamp:datetime,Level:string"). Use this before writing a KQL query against an unfamiliar table.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
|
||||
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
|
||||
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
|
||||
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
|
||||
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
|
||||
| `database` | string | Yes | Database containing the table |
|
||||
| `table` | string | Yes | Table whose schema should be read |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tableName` | string | Name of the table |
|
||||
| `schema` | string | Comma-separated CSL column schema \(name:type\) |
|
||||
| `databaseName` | string | The table's database |
|
||||
| `folder` | string | The table's folder |
|
||||
| `docString` | string | The table's docstring |
|
||||
|
||||
### Azure Data Explorer Show Database Schema
|
||||
|
||||
Read the full schema of an Azure Data Explorer database as a flat list of every table and column, so an agent can discover the data model in one call.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
|
||||
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
|
||||
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
|
||||
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
|
||||
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
|
||||
| `database` | string | Yes | Database whose schema should be read |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tableName` | string | Name Kusto assigned to the returned result table |
|
||||
| `columns` | array | Column metadata for the result table |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Kusto scalar type |
|
||||
| ↳ `dataType` | string | Approximate .NET type |
|
||||
| `rows` | array | Result rows as positional arrays matching the columns order |
|
||||
| `records` | array | Result rows keyed by column name |
|
||||
| `rowCount` | number | Rows carried in this result, after the row cap |
|
||||
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
|
||||
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
|
||||
|
||||
### Azure Data Explorer Show Table Details
|
||||
|
||||
Read size, row count, hot-cache footprint, and effective policies for a table — or for every table in the database when no table is given. Use it to see how much data a table actually holds before querying it.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
|
||||
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
|
||||
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
|
||||
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
|
||||
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
|
||||
| `database` | string | Yes | Database to read table details from |
|
||||
| `table` | string | No | Table to describe. Omit to describe every table in the database |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tableName` | string | Name Kusto assigned to the returned result table |
|
||||
| `columns` | array | Column metadata for the result table |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Kusto scalar type |
|
||||
| ↳ `dataType` | string | Approximate .NET type |
|
||||
| `rows` | array | Result rows as positional arrays matching the columns order |
|
||||
| `records` | array | Result rows keyed by column name |
|
||||
| `rowCount` | number | Rows carried in this result, after the row cap |
|
||||
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
|
||||
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
|
||||
|
||||
### Azure Data Explorer List Functions
|
||||
|
||||
List the stored functions in an Azure Data Explorer database, with their parameters and bodies, so an agent can reuse existing logic instead of rewriting it.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
|
||||
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
|
||||
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
|
||||
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
|
||||
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
|
||||
| `database` | string | Yes | Database whose stored functions should be listed |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tableName` | string | Name Kusto assigned to the returned result table |
|
||||
| `columns` | array | Column metadata for the result table |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Kusto scalar type |
|
||||
| ↳ `dataType` | string | Approximate .NET type |
|
||||
| `rows` | array | Result rows as positional arrays matching the columns order |
|
||||
| `records` | array | Result rows keyed by column name |
|
||||
| `rowCount` | number | Rows carried in this result, after the row cap |
|
||||
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
|
||||
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
|
||||
| `functions` | array | Stored function names, read from the Name column |
|
||||
|
||||
### Azure Data Explorer Ingest Inline
|
||||
|
||||
Push rows directly into an Azure Data Explorer table with .ingest inline. Data is parsed as CSV against the table schema unless an ingestion property says otherwise. Intended for small batches — use queued or streaming ingestion for production volumes.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
|
||||
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
|
||||
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
|
||||
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
|
||||
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
|
||||
| `database` | string | Yes | Database containing the target table |
|
||||
| `table` | string | Yes | Table to ingest into. Its schema is the assumed schema for the data |
|
||||
| `data` | string | Yes | Rows to ingest, one record per line, parsed as CSV by default \(e.g., "Shoes,1000\\nWide Shoes,50"\) |
|
||||
| `ingestionProperties` | string | No | Ingestion properties clause contents, e.g. format="json", ingestionMappingReference="mymapping" |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tableName` | string | Name Kusto assigned to the returned result table |
|
||||
| `columns` | array | Column metadata for the result table |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Kusto scalar type |
|
||||
| ↳ `dataType` | string | Approximate .NET type |
|
||||
| `rows` | array | Result rows as positional arrays matching the columns order |
|
||||
| `records` | array | Result rows keyed by column name |
|
||||
| `rowCount` | number | Rows carried in this result, after the row cap |
|
||||
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
|
||||
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
|
||||
| `extentIds` | array | Extent IDs created by the ingestion — one per data shard. A single empty or zero-valued ID means no data shard was generated |
|
||||
|
||||
### Azure Data Explorer Ingest From Query
|
||||
|
||||
Materialize the result of a KQL query into a table with .set, .append, .set-or-append, or .set-or-replace. Use this to build rollup or summary tables instead of pushing rows from a workflow. Kusto matches the query result to the target table by column type and position, NOT by column name, so project the columns in exactly the table's order or the data lands in the wrong columns.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
|
||||
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
|
||||
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
|
||||
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
|
||||
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
|
||||
| `database` | string | Yes | Database containing the target table |
|
||||
| `table` | string | Yes | Table to ingest the query result into |
|
||||
| `mode` | string | No | set \(create, fail if it exists\), append \(add to an existing table\), set-or-append \(default\), or set-or-replace \(replace all data\) |
|
||||
| `sourceQuery` | string | Yes | KQL query whose result becomes the ingested data \(e.g., LogsTable \| where Level == "Error" \| where Timestamp > ago\(1h\)\). Project the columns in the target table\'s order — matching is positional, not by name |
|
||||
| `async` | boolean | No | Return immediately with an OperationId and keep ingesting in the background. Check progress with Show Operations |
|
||||
| `ingestionProperties` | string | No | Optional ingestion properties clause contents, e.g. distributed=true, tags='\["daily"\]' |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tableName` | string | Name Kusto assigned to the returned result table |
|
||||
| `columns` | array | Column metadata for the result table |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Kusto scalar type |
|
||||
| ↳ `dataType` | string | Approximate .NET type |
|
||||
| `rows` | array | Result rows as positional arrays matching the columns order |
|
||||
| `records` | array | Result rows keyed by column name |
|
||||
| `rowCount` | number | Rows carried in this result, after the row cap |
|
||||
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
|
||||
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
|
||||
|
||||
### Azure Data Explorer Create Table
|
||||
|
||||
Create a table in an Azure Data Explorer database from a CSL column schema. Succeeds without changing anything if a table of the same name already exists.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
|
||||
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
|
||||
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
|
||||
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
|
||||
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
|
||||
| `database` | string | Yes | Database to create the table in |
|
||||
| `table` | string | Yes | Name of the table to create |
|
||||
| `columnSchema` | string | Yes | Comma-separated CSL column schema \(e.g., Timestamp:datetime, Level:string, Count:long\) |
|
||||
| `tableProperties` | string | No | Optional table properties clause contents, e.g. docstring="Raw logs", folder="Ingest" |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tableName` | string | Name Kusto assigned to the returned result table |
|
||||
| `columns` | array | Column metadata for the result table |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Kusto scalar type |
|
||||
| ↳ `dataType` | string | Approximate .NET type |
|
||||
| `rows` | array | Result rows as positional arrays matching the columns order |
|
||||
| `records` | array | Result rows keyed by column name |
|
||||
| `rowCount` | number | Rows carried in this result, after the row cap |
|
||||
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
|
||||
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
|
||||
|
||||
### Azure Data Explorer Drop Table
|
||||
|
||||
Drop a table from an Azure Data Explorer database. This permanently deletes the table and its data, and returns the tables that remain.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
|
||||
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
|
||||
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
|
||||
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
|
||||
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
|
||||
| `database` | string | Yes | Database containing the table |
|
||||
| `table` | string | Yes | Name of the table to drop |
|
||||
| `ifExists` | boolean | No | Succeed instead of failing when the table does not exist |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tableName` | string | Name Kusto assigned to the returned result table |
|
||||
| `columns` | array | Column metadata for the result table |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Kusto scalar type |
|
||||
| ↳ `dataType` | string | Approximate .NET type |
|
||||
| `rows` | array | Result rows as positional arrays matching the columns order |
|
||||
| `records` | array | Result rows keyed by column name |
|
||||
| `rowCount` | number | Rows carried in this result, after the row cap |
|
||||
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
|
||||
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
|
||||
| `tables` | array | Tables remaining in the database, read from the TableName column |
|
||||
|
||||
### Azure Data Explorer Show Ingestion Failures
|
||||
|
||||
List ingestion failures recorded for a database, with the failing table, error code, root cause detail, and whether the failure is permanent or transient. Failures are retained for 14 days.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
|
||||
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
|
||||
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
|
||||
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
|
||||
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
|
||||
| `database` | string | Yes | Database whose ingestion failures should be listed |
|
||||
| `operationId` | string | No | Limit results to a single ingestion operation ID |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tableName` | string | Name Kusto assigned to the returned result table |
|
||||
| `columns` | array | Column metadata for the result table |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Kusto scalar type |
|
||||
| ↳ `dataType` | string | Approximate .NET type |
|
||||
| `rows` | array | Result rows as positional arrays matching the columns order |
|
||||
| `records` | array | Result rows keyed by column name |
|
||||
| `rowCount` | number | Rows carried in this result, after the row cap |
|
||||
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
|
||||
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
|
||||
|
||||
### Azure Data Explorer Show Operations
|
||||
|
||||
Check the state of administrative operations on a cluster, such as an async ingestion. Given an operation ID it returns that operation latest update; with no ID it returns the operations from the last two weeks.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
|
||||
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
|
||||
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
|
||||
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
|
||||
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
|
||||
| `database` | string | No | Database context for the command |
|
||||
| `operationId` | string | No | Operation ID to check, e.g. the ID returned by an async ingestion |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tableName` | string | Name Kusto assigned to the returned result table |
|
||||
| `columns` | array | Column metadata for the result table |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Kusto scalar type |
|
||||
| ↳ `dataType` | string | Approximate .NET type |
|
||||
| `rows` | array | Result rows as positional arrays matching the columns order |
|
||||
| `records` | array | Result rows keyed by column name |
|
||||
| `rowCount` | number | Rows carried in this result, after the row cap |
|
||||
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
|
||||
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
|
||||
|
||||
|
||||
@@ -207,7 +207,7 @@ List all alert rules in the Grafana instance
|
||||
| ↳ `folderUID` | string | Parent folder UID |
|
||||
| ↳ `ruleGroup` | string | Rule group name |
|
||||
| ↳ `orgID` | number | Organization ID |
|
||||
| ↳ `provenance` | string | Provisioning source \(empty if API-managed\) |
|
||||
| ↳ `provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI |
|
||||
| ↳ `notification_settings` | json | Per-rule notification settings \(overrides\) |
|
||||
| ↳ `record` | json | Recording rule configuration \(recording rules only\) |
|
||||
|
||||
@@ -245,7 +245,7 @@ Get a specific alert rule by its UID
|
||||
| `folderUID` | string | Parent folder UID |
|
||||
| `ruleGroup` | string | Rule group name |
|
||||
| `orgID` | number | Organization ID |
|
||||
| `provenance` | string | Provisioning source \(empty if API-managed\) |
|
||||
| `provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI |
|
||||
| `notification_settings` | json | Per-rule notification settings \(overrides\) |
|
||||
| `record` | json | Recording rule configuration \(recording rules only\) |
|
||||
|
||||
@@ -266,8 +266,8 @@ Create a new alert rule
|
||||
| `condition` | string | No | The refId of the query or expression to use as the alert condition \(required for alerting rules; omit for recording rules\) |
|
||||
| `data` | string | Yes | JSON array of query/expression data objects |
|
||||
| `forDuration` | string | No | Duration to wait before firing \(e.g., 5m, 1h\) |
|
||||
| `noDataState` | string | No | State when no data is returned \(NoData, Alerting, OK\) |
|
||||
| `execErrState` | string | No | State on execution error \(Error, Alerting, OK\) |
|
||||
| `noDataState` | string | No | State when no data is returned: NoData \(default\), Alerting, OK, or KeepLast. Ignored for recording rules |
|
||||
| `execErrState` | string | No | State on execution error: Error \(default\), Alerting, OK, or KeepLast. Ignored for recording rules |
|
||||
| `annotations` | string | No | JSON object of annotations |
|
||||
| `labels` | string | No | JSON object of labels |
|
||||
| `uid` | string | No | Optional custom UID for the alert rule |
|
||||
@@ -299,7 +299,7 @@ Create a new alert rule
|
||||
| `folderUID` | string | Parent folder UID |
|
||||
| `ruleGroup` | string | Rule group name |
|
||||
| `orgID` | number | Organization ID |
|
||||
| `provenance` | string | Provisioning source \(empty if API-managed\) |
|
||||
| `provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI |
|
||||
| `notification_settings` | json | Per-rule notification settings \(overrides\) |
|
||||
| `record` | json | Recording rule configuration \(recording rules only\) |
|
||||
|
||||
@@ -353,7 +353,7 @@ Update an existing alert rule. Fetches the current rule and merges your changes.
|
||||
| `folderUID` | string | Parent folder UID |
|
||||
| `ruleGroup` | string | Rule group name |
|
||||
| `orgID` | number | Organization ID |
|
||||
| `provenance` | string | Provisioning source \(empty if API-managed\) |
|
||||
| `provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI |
|
||||
| `notification_settings` | json | Per-rule notification settings \(overrides\) |
|
||||
| `record` | json | Recording rule configuration \(recording rules only\) |
|
||||
|
||||
@@ -397,9 +397,9 @@ List all alert notification contact points
|
||||
| ↳ `uid` | string | Contact point UID |
|
||||
| ↳ `name` | string | Contact point name |
|
||||
| ↳ `type` | string | Notification type \(email, slack, etc.\) |
|
||||
| ↳ `settings` | object | Type-specific settings |
|
||||
| ↳ `settings` | json | Type-specific settings |
|
||||
| ↳ `disableResolveMessage` | boolean | Whether resolve messages are disabled |
|
||||
| ↳ `provenance` | string | Provisioning source \(empty if API-managed\) |
|
||||
| ↳ `provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI |
|
||||
|
||||
### Grafana Create Contact Point
|
||||
|
||||
@@ -427,7 +427,7 @@ Create a notification contact point (e.g., Slack, email, PagerDuty)
|
||||
| `type` | string | Receiver type |
|
||||
| `settings` | json | Type-specific settings |
|
||||
| `disableResolveMessage` | boolean | Whether resolve notifications are suppressed |
|
||||
| `provenance` | string | Provisioning source \(empty if API-managed\) |
|
||||
| `provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI |
|
||||
|
||||
### Grafana Create Annotation
|
||||
|
||||
@@ -474,7 +474,7 @@ Query annotations by time range, dashboard, or tags
|
||||
| `userId` | number | No | Filter by ID of the user who created the annotation |
|
||||
| `tags` | string | No | Comma-separated list of tags to filter by |
|
||||
| `type` | string | No | Filter by type \(alert or annotation\) |
|
||||
| `limit` | number | No | Maximum number of annotations to return |
|
||||
| `limit` | number | No | Maximum number of annotations to return \(Grafana defaults to 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -518,8 +518,8 @@ Update an existing annotation
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | number | The ID of the updated annotation |
|
||||
| `message` | string | Confirmation message |
|
||||
| `annotationId` | number | The annotation that was updated, echoed from the request — Grafana answers a patch with only a message and returns no id |
|
||||
| `message` | string | Confirmation message from Grafana, e.g. "Annotation patched" |
|
||||
|
||||
### Grafana Delete Annotation
|
||||
|
||||
@@ -587,7 +587,7 @@ Get a data source by its ID or UID
|
||||
| `apiKey` | string | Yes | Grafana Service Account Token |
|
||||
| `baseUrl` | string | Yes | Grafana instance URL \(e.g., https://your-grafana.com\) |
|
||||
| `organizationId` | string | No | Organization ID for multi-org Grafana instances \(e.g., 1, 2\) |
|
||||
| `dataSourceId` | string | Yes | The ID or UID of the data source to retrieve \(e.g., prometheus, P1234AB5678\) |
|
||||
| `dataSourceId` | string | Yes | The UID of the data source to retrieve \(e.g., P1234AB5678\). Numeric ids are not supported — Grafana serves those only behind a disabled-by-default feature toggle |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -629,8 +629,9 @@ Test connectivity to a data source by its UID
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | string | Health status of the data source \(e.g., OK\) |
|
||||
| `message` | string | Detailed health message from the data source |
|
||||
| `status` | string | Verdict Grafana returned for the data source, e.g. OK or ERROR. An unhealthy source reports here rather than failing the tool |
|
||||
| `message` | string | The plugin's diagnostic detail, which carries the reason on a failed check |
|
||||
| `details` | json | Extra structured detail, when the data source plugin supplies any |
|
||||
|
||||
### Grafana List Folders
|
||||
|
||||
@@ -788,8 +789,9 @@ Delete a folder by its UID
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `uid` | string | The UID of the deleted folder |
|
||||
| `message` | string | Confirmation message |
|
||||
| `id` | number | Numeric id of the deleted folder, as returned by Grafana |
|
||||
| `uid` | string | The UID that was deleted, echoed from the request |
|
||||
| `message` | string | Grafana's confirmation message |
|
||||
|
||||
### Grafana Get Health
|
||||
|
||||
@@ -811,4 +813,154 @@ Check the health of the Grafana instance (version, database status)
|
||||
| `database` | string | Database health status \(e.g., ok\) |
|
||||
| `version` | string | Grafana version |
|
||||
|
||||
### Grafana Update Contact Point
|
||||
|
||||
Replace a contact point by its UID. Grafana has no partial update for contact points, so every field is rewritten — resend the name, type, and full settings, or the omitted ones are reset.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Grafana Service Account Token |
|
||||
| `baseUrl` | string | Yes | Grafana instance URL \(e.g., https://your-grafana.com\) |
|
||||
| `organizationId` | string | No | Organization ID for multi-org Grafana instances \(e.g., 1, 2\) |
|
||||
| `contactPointUid` | string | Yes | UID of the contact point to replace |
|
||||
| `name` | string | Yes | Contact point name. Grafana groups receivers that share a name |
|
||||
| `type` | string | Yes | Receiver type, e.g. slack, email, pagerduty, webhook, opsgenie, teams, discord, telegram |
|
||||
| `settings` | string | Yes | JSON object of receiver settings for this type, e.g. \{"url":"https://hooks.slack.com/..."\} for slack |
|
||||
| `disableResolveMessage` | boolean | No | Suppress the resolved notification. Omitting this resets it to false |
|
||||
| `disableProvenance` | boolean | No | Send X-Disable-Provenance. Use only on a contact point whose provenance is already empty \(UI-created, or created by Sim with this on\) — sending it against an API-provisioned contact point is rejected with 403 |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `uid` | string | The UID that was updated, echoed from the request — Grafana answers a contact point update with only a message and returns no object |
|
||||
| `message` | string | Confirmation message from Grafana, e.g. "contactpoint updated" |
|
||||
|
||||
### Grafana Delete Contact Point
|
||||
|
||||
Permanently delete a contact point by its UID. Grafana refuses the delete while the contact point is still referenced by the notification policy tree or by an alert rule.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Grafana Service Account Token |
|
||||
| `baseUrl` | string | Yes | Grafana instance URL \(e.g., https://your-grafana.com\) |
|
||||
| `organizationId` | string | No | Organization ID for multi-org Grafana instances \(e.g., 1, 2\) |
|
||||
| `contactPointUid` | string | Yes | UID of the contact point to delete |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `uid` | string | The UID that was deleted, echoed from the request |
|
||||
| `message` | string | Confirmation message from Grafana, e.g. "contactpoint deleted" |
|
||||
|
||||
### Grafana Move Folder
|
||||
|
||||
Move a folder under a different parent folder, or to the root by leaving the parent empty. Returns the folder with its new ancestry.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Grafana Service Account Token |
|
||||
| `baseUrl` | string | Yes | Grafana instance URL \(e.g., https://your-grafana.com\) |
|
||||
| `organizationId` | string | No | Organization ID for multi-org Grafana instances \(e.g., 1, 2\) |
|
||||
| `folderUid` | string | Yes | UID of the folder to move |
|
||||
| `parentUid` | string | No | UID of the new parent folder. Leave empty to move the folder to the root |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | number | The numeric ID of the folder |
|
||||
| `uid` | string | The UID of the folder |
|
||||
| `title` | string | The title of the folder |
|
||||
| `url` | string | The URL path of the folder |
|
||||
| `parentUid` | string | UID of the new parent folder, absent once moved to the root |
|
||||
| `parents` | array | Folder ancestry from the root down to the parent \(uid, title, url\) |
|
||||
| `hasAcl` | boolean | Whether the folder has custom ACL permissions |
|
||||
| `canSave` | boolean | Whether the caller can save the folder |
|
||||
| `canEdit` | boolean | Whether the caller can edit the folder |
|
||||
| `canAdmin` | boolean | Whether the caller can administer the folder |
|
||||
| `createdBy` | string | Login that created the folder |
|
||||
| `created` | string | Creation timestamp |
|
||||
| `updatedBy` | string | Login that last updated the folder |
|
||||
| `updated` | string | Last update timestamp |
|
||||
| `version` | number | Folder revision number |
|
||||
|
||||
### Grafana Get Alert Rule Group
|
||||
|
||||
Read an alert rule group: its evaluation interval and every rule in it. The interval is the group-level knob that decides how often those rules are evaluated, which the individual alert rule operations do not expose.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Grafana Service Account Token |
|
||||
| `baseUrl` | string | Yes | Grafana instance URL \(e.g., https://your-grafana.com\) |
|
||||
| `organizationId` | string | No | Organization ID for multi-org Grafana instances \(e.g., 1, 2\) |
|
||||
| `folderUid` | string | Yes | UID of the folder holding the rule group |
|
||||
| `ruleGroup` | string | Yes | Name of the rule group |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `title` | string | Name of the rule group |
|
||||
| `folderUid` | string | UID of the folder holding the group |
|
||||
| `interval` | number | How often the group is evaluated, as an integer. Grafana returns seconds here rather than a duration string |
|
||||
| `rules` | array | Provisioned alert rules in the group |
|
||||
| ↳ `id` | number | Alert rule numeric ID |
|
||||
| ↳ `uid` | string | Alert rule UID |
|
||||
| ↳ `title` | string | Alert rule title |
|
||||
| ↳ `condition` | string | RefId of the query used as the alert condition |
|
||||
| ↳ `data` | json | Alert rule query/expression data array |
|
||||
| ↳ `updated` | string | Last update timestamp |
|
||||
| ↳ `noDataState` | string | State when no data is returned |
|
||||
| ↳ `execErrState` | string | State on execution error |
|
||||
| ↳ `for` | string | Duration the condition must hold before firing |
|
||||
| ↳ `keepFiringFor` | string | Duration to keep firing after condition stops |
|
||||
| ↳ `missingSeriesEvalsToResolve` | number | Number of missing series evaluations before resolving |
|
||||
| ↳ `annotations` | json | Alert annotations |
|
||||
| ↳ `labels` | json | Alert labels |
|
||||
| ↳ `isPaused` | boolean | Whether the rule is paused |
|
||||
| ↳ `folderUID` | string | Parent folder UID |
|
||||
| ↳ `ruleGroup` | string | Rule group name |
|
||||
| ↳ `orgID` | number | Organization ID |
|
||||
| ↳ `provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI |
|
||||
| ↳ `notification_settings` | json | Per-rule notification settings \(overrides\) |
|
||||
| ↳ `record` | json | Recording rule configuration \(recording rules only\) |
|
||||
|
||||
### Grafana Query Data Source
|
||||
|
||||
Run one or more queries against a Grafana data source that has a backend implementation, and read the values back. This is how you get actual metric numbers out of Grafana rather than dashboard or alert configuration.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Grafana Service Account Token |
|
||||
| `baseUrl` | string | Yes | Grafana instance URL \(e.g., https://your-grafana.com\) |
|
||||
| `organizationId` | string | No | Organization ID for multi-org Grafana instances \(e.g., 1, 2\) |
|
||||
| `queries` | string | Yes | JSON array of at least one query. Each needs a datasource.uid and a refId, plus the fields that data source expects — expr for Prometheus, rawSql for SQL. Example: \[\{"refId":"A","datasource":\{"uid":"P123"\},"expr":"up","format":"time_series"\}\] |
|
||||
| `from` | string | No | Start of the time range, either epoch milliseconds or Grafana relative time \(e.g., now-5m\). Defaults to now-1h |
|
||||
| `to` | string | No | End of the time range, epoch milliseconds or relative \(e.g., now\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `results` | json | Raw Grafana response, keyed by each query refId, each holding the frames that query produced |
|
||||
| `series` | array | The same frames flattened into rows, so values can be read without walking the columnar layout |
|
||||
| ↳ `refId` | string | The query this frame came from |
|
||||
| ↳ `fields` | array | Field metadata in column order |
|
||||
| ↳ `name` | string | Field name, e.g. time or A-series |
|
||||
| ↳ `type` | string | Field type, e.g. time or number |
|
||||
| ↳ `rowCount` | number | Number of rows in the frame |
|
||||
| ↳ `rows` | array | Rows keyed by field name |
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
---
|
||||
title: LogRocket
|
||||
description: Summarize sessions, manage users, and tag releases in LogRocket
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="logrocket"
|
||||
color="#764ABC"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[LogRocket](https://logrocket.com/) is a session replay and product analytics platform for frontend applications. It records what users actually did — clicks, navigation, network requests, console output, and application state — so teams can see the exact path that led to a bug, a drop-off, or a support ticket instead of asking the customer to reproduce it.
|
||||
|
||||
With LogRocket, you can:
|
||||
|
||||
- **Summarize sessions with AI**: Galileo Highlights reads a user's sessions and answers a plain-English question about them, returning a markdown summary plus per-session breakdowns.
|
||||
- **Identify users**: Attach names, emails, and custom traits to a user so sessions can be segmented by plan, lifecycle stage, or account value.
|
||||
- **Export session data**: Pull download URLs for JSON Lines session exports and load them into a warehouse.
|
||||
- **Audit session access**: Page through the audit log to see who viewed which sessions and when.
|
||||
- **Tag releases**: Register a deployed version so uploaded source maps decode its stack traces.
|
||||
|
||||
Sim's LogRocket integration lets your agents run these operations programmatically. Use it to enrich a support ticket with a session summary, turn a vague bug report into reproduction steps, keep user traits in sync with your CRM, or register a release as part of a deploy workflow.
|
||||
|
||||
Highlights requests are asynchronous: **Request Highlights** returns an ID and **Get Highlights** reports `PENDING` until generation finishes (typically one to three minutes), then `READY` or `FAILED`. Poll it rather than expecting the summary immediately.
|
||||
|
||||
LogRocket has no REST API for querying sessions, issues, or metrics directly — that data is available through their [MCP server](https://docs.logrocket.com/docs/mcp) at `https://mcp.logrocket.com/mcp`, which you can add to Sim as an MCP server connection alongside this block.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate LogRocket into your workflow to request AI session highlights for a user, poll for the result, list exported session files, read the audit log, create or update user profiles, and register releases so source maps decode their stack traces.
|
||||
|
||||
|
||||
|
||||
## Actions
|
||||
|
||||
### LogRocket Request Highlights
|
||||
|
||||
Start a Galileo session highlights job for a user. Returns a request ID to poll with LogRocket's Get Highlights operation.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | LogRocket API key |
|
||||
| `orgId` | string | Yes | LogRocket organization ID |
|
||||
| `appId` | string | Yes | LogRocket project \(app\) ID |
|
||||
| `apiHost` | string | No | API host override for Private Cloud deployments |
|
||||
| `userEmail` | string | No | Email of the user to summarize. Required if no user ID is given. |
|
||||
| `userID` | string | No | ID of the user to summarize. Required if no email is given. |
|
||||
| `question` | string | No | Question to focus the highlights on |
|
||||
| `startMs` | string | No | Start of the session window, in milliseconds since epoch |
|
||||
| `endMs` | string | No | End of the session window, in milliseconds since epoch |
|
||||
| `webhookURL` | string | No | URL notified when the highlights job finishes |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Request ID used to retrieve the highlights result |
|
||||
|
||||
### LogRocket Get Highlights
|
||||
|
||||
Retrieve the result of a Galileo session highlights request. Status is PENDING until generation finishes, then READY or FAILED.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | LogRocket API key |
|
||||
| `orgId` | string | Yes | LogRocket organization ID |
|
||||
| `appId` | string | Yes | LogRocket project \(app\) ID |
|
||||
| `apiHost` | string | No | API host override for Private Cloud deployments |
|
||||
| `id` | string | Yes | Request ID returned by the Request Highlights operation |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | string | Job status: PENDING, READY, or FAILED |
|
||||
| `requestID` | string | ID of the highlights request |
|
||||
| `appID` | string | LogRocket project the request belongs to |
|
||||
| `highlights` | string | Markdown summary across the matched sessions. Present when status is READY. |
|
||||
| `sessions` | array | Per-session highlights |
|
||||
| ↳ `recordingID` | string | LogRocket recording ID |
|
||||
| ↳ `sessionID` | number | Session number within the recording |
|
||||
| ↳ `highlights` | string | Highlights for this session |
|
||||
|
||||
### LogRocket List Exported Sessions
|
||||
|
||||
List exported session files from the LogRocket Data Export API. Returns download URLs for JSON Lines exports, oldest first, plus a cursor for the next page.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | LogRocket API key |
|
||||
| `orgId` | string | Yes | LogRocket organization ID |
|
||||
| `appId` | string | Yes | LogRocket project \(app\) ID |
|
||||
| `apiHost` | string | No | API host override for Private Cloud deployments |
|
||||
| `cursor` | string | No | Cursor from a previous page, used to fetch newer sessions |
|
||||
| `limit` | string | No | Results per page. Defaults to 10, maximum 100. |
|
||||
| `date` | string | No | Unix timestamp in milliseconds to start listing from |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `sessions` | array | Exported session files |
|
||||
| ↳ `url` | string | Download URL for the JSON Lines export file |
|
||||
| `cursor` | string | Opaque cursor for the next page of results |
|
||||
|
||||
### LogRocket Get Audit Logs
|
||||
|
||||
Export audit log entries from LogRocket, recording who viewed sessions and what actions were taken. Paginated with a cursor.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | LogRocket API key |
|
||||
| `orgId` | string | Yes | LogRocket organization ID |
|
||||
| `appId` | string | Yes | LogRocket project \(app\) ID |
|
||||
| `apiHost` | string | No | API host override for Private Cloud deployments |
|
||||
| `limit` | string | No | Number of audit log records to return |
|
||||
| `cursor` | string | No | Cursor from a previous page, used to fetch the next page |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `logs` | array | Audit log entries |
|
||||
| ↳ `time` | string | Formatted timestamp of the action |
|
||||
| ↳ `createdDate` | string | ISO 8601 timestamp of the action |
|
||||
| ↳ `user` | string | Email or system ID of the actor |
|
||||
| ↳ `action` | string | Action taken, e.g. Viewed session |
|
||||
| ↳ `description` | string | Action details, e.g. the session ID |
|
||||
| `cursor` | string | Opaque cursor for the next page of results |
|
||||
| `hasNext` | boolean | Whether more audit logs exist beyond this page |
|
||||
|
||||
### LogRocket Identify User
|
||||
|
||||
Create or update a LogRocket user profile with demographic, financial, and engagement traits.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | LogRocket API key |
|
||||
| `orgId` | string | Yes | LogRocket organization ID |
|
||||
| `appId` | string | Yes | LogRocket project \(app\) ID |
|
||||
| `apiHost` | string | No | API host override for Private Cloud deployments |
|
||||
| `userId` | string | Yes | ID of the user to create or update |
|
||||
| `name` | string | No | Display name of the user. Maximum 1024 characters. |
|
||||
| `email` | string | No | Email of the user. Maximum 1024 characters. |
|
||||
| `timestamp` | string | No | Unix timestamp in milliseconds describing when the submitted data was true |
|
||||
| `traits` | string | No | JSON object of custom traits. Each key and value is limited to 1024 characters and stored as a string. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `userID` | string | ID of the created or updated user |
|
||||
| `name` | string | Display name stored on the profile |
|
||||
| `email` | string | Email stored on the profile |
|
||||
| `traits` | json | Custom traits stored on the profile, with every value coerced to a string |
|
||||
|
||||
### LogRocket Create Release
|
||||
|
||||
Register a release version in LogRocket so uploaded source maps can decode stack traces for it. Fails with a conflict if the version already exists.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | LogRocket API key |
|
||||
| `orgId` | string | Yes | LogRocket organization ID |
|
||||
| `appId` | string | Yes | LogRocket project \(app\) ID |
|
||||
| `apiHost` | string | No | API host override for Private Cloud deployments |
|
||||
| `version` | string | Yes | Release version to register, e.g. 1.2.3 or a commit SHA |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `version` | string | Release version that was registered |
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"atlassian-service-account",
|
||||
"attio",
|
||||
"attio-service-account",
|
||||
"azure_data_explorer",
|
||||
"azure_devops",
|
||||
"box",
|
||||
"box-service-account",
|
||||
@@ -143,6 +144,7 @@
|
||||
"linkup",
|
||||
"linq",
|
||||
"logfire",
|
||||
"logrocket",
|
||||
"logs",
|
||||
"loops",
|
||||
"luma",
|
||||
@@ -164,6 +166,8 @@
|
||||
"mongodb",
|
||||
"mysql",
|
||||
"neo4j",
|
||||
"netsuite",
|
||||
"netsuite-service-account",
|
||||
"neverbounce",
|
||||
"new_relic",
|
||||
"notion",
|
||||
@@ -191,6 +195,7 @@
|
||||
"qdrant",
|
||||
"quartr",
|
||||
"quiver",
|
||||
"rabbitmq",
|
||||
"railway",
|
||||
"rb2b",
|
||||
"rds",
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
title: Oracle NetSuite Service Account
|
||||
description: Configure certificate-based OAuth 2.0 client credentials once and reuse them across NetSuite blocks
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps'
|
||||
import { FAQ } from '@/components/ui/faq'
|
||||
|
||||
Oracle NetSuite authenticates SuiteTalk machine-to-machine clients with a signed JWT and a certificate mapping. Sim stores the SuiteTalk URL, Client ID, Certificate ID, and private key as one encrypted service-account credential. Every NetSuite block stores only that credential's ID; Sim signs the assertion and injects the short-lived access token on the server.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A dedicated NetSuite integration role with **REST Web Services** and **Log in using OAuth 2.0 Access Tokens**, plus the record and SuiteAnalytics permissions your workflows require.
|
||||
- An integration record with **Client Credentials (Machine to Machine) Grant** and the **REST Web Services** scope enabled.
|
||||
- A 3072- or 4096-bit RSA key pair, or a P-256, P-384, or P-521 EC key pair, and a public certificate generated through your organization's certificate process.
|
||||
- Access to **OAuth 2.0 Client Credentials (M2M) Setup** and **Company URLs** in the target NetSuite environment.
|
||||
|
||||
<Callout type="warn">
|
||||
Create and map credentials separately in production, sandbox, and Release Preview. A sandbox refresh removes its OAuth 2.0 client-credential mappings, and each environment has a different authoritative SuiteTalk URL.
|
||||
</Callout>
|
||||
|
||||
## Configure NetSuite
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
In **Setup → Company → Enable Features**, enable **REST Web Services** and **OAuth 2.0**. Enable **SuiteAnalytics Workbook** if workflows will use datasets.
|
||||
</Step>
|
||||
<Step>
|
||||
Create a dedicated integration role and grant only the record, transaction, subsidiary, and analytics permissions the workflows need. Avoid using Administrator.
|
||||
</Step>
|
||||
<Step>
|
||||
Under **Setup → Integration → Manage Integrations**, create or edit an integration, enable the machine-to-machine client-credentials grant and REST Web Services scope, then save its **Client ID**.
|
||||
</Step>
|
||||
<Step>
|
||||
Upload only the public certificate under **OAuth 2.0 Client Credentials (M2M) Setup**. Map it to the integration, entity, and dedicated role, then save the generated **Certificate ID**. Keep the private key outside NetSuite.
|
||||
</Step>
|
||||
<Step>
|
||||
Under **Setup → Company → Company Information → Company URLs**, copy the complete **SuiteTalk (SOAP and REST Web Services)** URL for this environment.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
Oracle documents the [role setup](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_157771510070.html), [integration record](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_157771733782.html), [certificate requirements](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/subsect_162755332391.html), and [client-credential mapping](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_162686838198.html).
|
||||
|
||||
## Add the Credential to Sim
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
Add an **Oracle NetSuite** block to a workflow and open the **NetSuite Account** dropdown.
|
||||
</Step>
|
||||
<Step>
|
||||
Choose to add a credential, then enter the authoritative SuiteTalk URL, Client ID, Certificate ID, and PEM private key that matches the uploaded certificate.
|
||||
</Step>
|
||||
<Step>
|
||||
Save the credential. Sim validates the URL and key policy, signs a client assertion, and performs a real token exchange before storing the encrypted credential.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
The private key is encrypted at rest and is never returned through the token endpoint or injected into a workflow tool. At execution time, Sim resolves the selected credential to a short-lived bearer token and the normalized SuiteTalk origin.
|
||||
|
||||
## Use Pickers and Manual Values
|
||||
|
||||
Selecting the credential enables these account-backed fields:
|
||||
|
||||
| Field | Lists | Additional scope |
|
||||
| --- | --- | --- |
|
||||
| Record Type | Up to 1,000 record types visible in the metadata catalog | credential |
|
||||
| Async Task | Up to 100 tasks belonging to a known batch job | job ID |
|
||||
|
||||
Picker results reflect the selected role's permissions. Switch any picker to Advanced mode to type an identifier or reference an upstream output. Enter SuiteAnalytics dataset IDs manually after finding them with **List SuiteAnalytics Datasets**. Record IDs, job IDs, transform targets, actions, fields, forms, subresources, and relationship IDs also remain manual because NetSuite does not expose bounded universal listings that would make those choices complete and reliable.
|
||||
|
||||
**Create Record** without `replace` returns HTTP 204 with no response body; with `replace`, it returns HTTP 201 and the created record object. Both responses expose NetSuite's validated `location`. The `replace` option applies to create and update, not upsert.
|
||||
|
||||
## Rotate or Revoke
|
||||
|
||||
To rotate a certificate, create and upload the replacement certificate and create its new NetSuite mapping. Then reconnect the existing Sim credential by re-entering all four required fields: SuiteTalk URL, Client ID, the new Certificate ID, and the replacement private key. Reconnecting changes the encrypted credential fingerprint, so later executions mint against the new material.
|
||||
|
||||
After confirming workflows succeed, remove the old certificate mapping in NetSuite so the previous certificate can no longer mint tokens. Deleting a Sim credential removes its workflow bindings but does not revoke the corresponding NetSuite certificate mapping.
|
||||
|
||||
<FAQ items={[
|
||||
{ question: "Why can’t I paste the key into each block?", answer: "The signing key is long-lived account material. Keeping it in one encrypted credential avoids duplicating it in workflow state and lets every block reuse the same verified account connection." },
|
||||
{ question: "Why is my picker empty?", answer: "The pickers use the selected integration role. Confirm that role can access the metadata catalog or async job, then use Advanced mode when you already know an identifier." },
|
||||
{ question: "Can one credential access production and sandbox?", answer: "No. Each environment has its own SuiteTalk URL and client-certificate mapping. Create one Sim credential per environment." },
|
||||
{ question: "Why did the credential stop working after a sandbox refresh?", answer: "NetSuite clears OAuth 2.0 client-credential mappings during a sandbox refresh. Recreate the mapping and reconnect the Sim credential with its new Certificate ID." },
|
||||
{ question: "Does deleting the credential revoke it in NetSuite?", answer: "No. Remove the certificate mapping in NetSuite as well when decommissioning or responding to a compromise." },
|
||||
]} />
|
||||
@@ -0,0 +1,653 @@
|
||||
---
|
||||
title: Oracle NetSuite
|
||||
description: Manage NetSuite records, queries, datasets, batches, and async jobs
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="netsuite"
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Oracle NetSuite](https://www.netsuite.com/) is a cloud ERP platform for financials, order management, inventory, procurement, CRM, and analytics. Sim connects through SuiteTalk REST Web Services using NetSuite's OAuth 2.0 client-credentials flow; it does not require a RESTlet or a user-interactive login.
|
||||
|
||||
## Before you connect
|
||||
|
||||
1. In **Setup → Company → Enable Features**, enable **REST Web Services** and **OAuth 2.0**. Enable **SuiteAnalytics Workbook** if you will list or execute datasets.
|
||||
2. Use a dedicated integration role. Grant it **REST Web Services** and **Log in using OAuth 2.0 Access Tokens**, plus the record, transaction, and subsidiary permissions needed by your workflows. Dataset access also requires **SuiteAnalytics Workbook** permission and access to the selected datasets. Oracle recommends a purpose-built role instead of Administrator. See [Set Up OAuth 2.0 Roles](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_157771510070.html) and [Prerequisites and Setup for REST Web Services](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/article_5085602973.html).
|
||||
3. Create or edit an integration record under **Setup → Integration → Manage Integrations**. Enable **Client Credentials (Machine to Machine) Grant** and the **REST Web Services** OAuth 2.0 scope, then save the **Client ID**. See [Create Integration Records for Applications to Use OAuth 2.0](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_157771733782.html).
|
||||
4. Create either a 3072- or 4096-bit RSA key pair or a P-256, P-384, or P-521 EC key pair and certificate using your organization's certificate process. Sim signs RSA assertions with PS256 and selects ES256, ES384, or ES512 for the corresponding EC curve. Keep the PEM private key secure; upload only the public certificate to NetSuite, and plan renewal because NetSuite limits certificate validity to two years. See [OAuth 2.0 Client Credentials Certificate Conditions](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/subsect_162755332391.html).
|
||||
5. Go to **Setup → Integration → Manage Authentication → OAuth 2.0 Client Credentials (M2M) Setup**. Create a mapping for the integration's entity, role, application, and public certificate, then save the generated **Certificate ID**. Oracle requires this mapping separately in production, sandbox, and Release Preview, and a sandbox refresh clears it. See [OAuth 2.0 Client Credentials Setup](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_162686838198.html).
|
||||
6. In **Setup → Company → Company Information → Company URLs**, copy the **SuiteTalk (SOAP and REST Web Services)** URL for the current environment. Production, sandbox, and Release Preview environments each have their own authoritative URL.
|
||||
7. Add a NetSuite block, open **NetSuite Account**, and create a reusable credential with the SuiteTalk URL, Client ID, Certificate ID, and matching private key. See [Oracle NetSuite service-account setup](/integrations/netsuite-service-account) for the complete setup and rotation workflow.
|
||||
|
||||
## Usage notes
|
||||
|
||||
- Record fields and supported actions vary by account, enabled features, custom records, forms, role, and permissions. Use **List Record Types** and **Get Record Metadata** before constructing create, update, upsert, action, or transform bodies. Sim intentionally accepts JSON for these dynamic record shapes instead of guessing a fixed schema.
|
||||
- Select the stored NetSuite account once per block. Record Type and known-job Async Task fields use account-backed pickers; switch a field to Advanced mode to type or reference an identifier that is not present in the bounded picker result. Enter SuiteAnalytics dataset IDs manually after finding them with **List SuiteAnalytics Datasets**.
|
||||
- **Create Record** without `replace` returns NetSuite's HTTP 204 response with no body; with `replace`, it returns HTTP 201 and the created record object. Both cases expose the validated `location` returned by NetSuite. The `replace` option applies to create and update, not upsert.
|
||||
- Paged operations return one page only. The default limit is 100, the maximum is 1,000, and the offset must be a non-negative multiple of the limit. Sim never fetches later pages automatically. Requests must stay within NetSuite's first 100,000 results and first 1,000 pages.
|
||||
- Homogeneous batch operations accept 1–100 records of one record type and always run asynchronously. NetSuite processes records in parallel, and individual tasks can fail independently; submission is not an all-or-none transaction. Preserve the returned `location` or `jobId`, use **Get Async Status** with **Job Status** until the job completes, choose **List Tasks** to collect task IDs, check each ID with **Task Status**, then use completed IDs with **Get Async Operation Result**. Canceling or timing out the local Sim request does not cancel a batch that NetSuite has already accepted.
|
||||
- Sim gives the OAuth token exchange and each SuiteTalk request up to 30 seconds. A timed-out request fails locally, but a mutation that NetSuite already accepted may still finish remotely.
|
||||
- Sim limits each materialized request body and successful SuiteTalk response to 16 MiB. Request JSON is also limited to 100 levels of nesting and 100,000 JSON values. Split work into smaller pages or batches when a request or response would exceed these ceilings, even if NetSuite would otherwise accept the payload.
|
||||
- When attaching a contact with a role, provide either the role's internal ID or external ID, not both. File attachments do not use a contact role.
|
||||
- **Attach/Detach**, homogeneous batch operations, **Get Record Form**, and **Get Select Options** require a NetSuite 2026.1-compatible account. Oracle introduced these SuiteTalk REST capabilities in [NetSuite 2026.1](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_N3950559.html).
|
||||
- **Get Governance Limits** returns data only for roles allowed by NetSuite; Oracle documents Administrator access for that operation.
|
||||
- This integration does not include a trigger. SuiteTalk has no generic API for registering record-change webhooks; polling or customer-deployed SuiteScript requires a separate design.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Connect a reusable Oracle NetSuite service-account credential to SuiteTalk REST Web Services. Read and write account-specific records, execute SuiteQL and SuiteAnalytics datasets, run asynchronous record batches, inspect metadata, and monitor async jobs.
|
||||
|
||||
|
||||
|
||||
## Actions
|
||||
|
||||
### NetSuite List/Search Records
|
||||
|
||||
List one page of a NetSuite record collection, optionally filtered with a q expression.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `q` | string | No | NetSuite record collection filter expression |
|
||||
| `limit` | number | No | Results to return in this page \(1-1000; default 100\) |
|
||||
| `offset` | number | No | Zero-based result offset; must be divisible by limit and stay within the first 100,000 results and 1,000 pages |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | One documented NetSuite collection page |
|
||||
| ↳ `links` | array | Oracle HATEOAS links for the response |
|
||||
| ↳ `rel` | string | Link relationship |
|
||||
| ↳ `href` | string | Link target |
|
||||
| ↳ `items` | array | Matching record references in this page |
|
||||
| ↳ `id` | string | NetSuite record ID |
|
||||
| ↳ `links` | array | Oracle HATEOAS links for the record |
|
||||
| ↳ `rel` | string | Link relationship |
|
||||
| ↳ `href` | string | Link target |
|
||||
| ↳ `count` | number | Number of items in this page |
|
||||
| ↳ `hasMore` | boolean | Whether another page is available |
|
||||
| ↳ `offset` | number | Offset of this page |
|
||||
| ↳ `totalResults` | number | Total number of matching items |
|
||||
|
||||
### NetSuite Get Record
|
||||
|
||||
Retrieve one NetSuite record by internal or external ID.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `recordId` | string | Yes | NetSuite internal ID or an external-ID reference beginning with eid: |
|
||||
| `fields` | string | No | Comma-separated record fields to return |
|
||||
| `expand` | string | No | Comma-separated resources to expand when supported by the record metadata |
|
||||
| `expandSubResources` | boolean | No | Whether to expand sublists and subrecords in the response |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | NetSuite response body; record fields are account-specific and dynamic |
|
||||
|
||||
### NetSuite Create Record
|
||||
|
||||
Create a NetSuite record using the account-specific record metadata schema.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `body` | json | Yes | Record fields matching the account-specific NetSuite metadata schema |
|
||||
| `replace` | string | No | Comma-separated sublists whose default lines should be replaced |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Empty for standard HTTP 204 creation; replacement creation can return the documented HTTP 201 post-state object |
|
||||
| `location` | string | Newly created record URL from the Location response header |
|
||||
|
||||
### NetSuite Update Record
|
||||
|
||||
Update fields on an existing NetSuite record with PATCH.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `recordId` | string | Yes | NetSuite internal ID or an external-ID reference beginning with eid: |
|
||||
| `body` | json | Yes | Record fields matching the account-specific NetSuite metadata schema |
|
||||
| `replace` | string | No | Comma-separated sublists whose existing lines should be replaced |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Empty for the documented HTTP 204 No Content response |
|
||||
| `location` | string | Updated record URL from the Location response header |
|
||||
|
||||
### NetSuite Upsert Record
|
||||
|
||||
Create or update a NetSuite record by external ID with PUT.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `externalId` | string | Yes | External ID without the eid: prefix |
|
||||
| `body` | json | Yes | Record fields matching the account-specific NetSuite metadata schema |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Empty for the documented HTTP 204 No Content response |
|
||||
| `location` | string | URL of the created or updated record, when NetSuite returns a Location header |
|
||||
|
||||
### NetSuite Delete Record
|
||||
|
||||
Delete one NetSuite record by internal or external ID.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `recordId` | string | Yes | NetSuite internal ID or an external-ID reference beginning with eid: |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Empty for the documented HTTP 204 No Content response |
|
||||
|
||||
### NetSuite Get Subresource
|
||||
|
||||
Retrieve a record sublist, subrecord, referenced record, or nested subresource.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `recordId` | string | Yes | NetSuite internal ID or an external-ID reference beginning with eid: |
|
||||
| `subresourcePath` | string | Yes | Slash-separated subresource path, such as item or item/1/inventoryDetail |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | NetSuite response body; record fields are account-specific and dynamic |
|
||||
|
||||
### NetSuite Get Record Form
|
||||
|
||||
Return a prepopulated create form, or an edit form when a record ID is supplied.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `recordId` | string | No | Existing record ID; omit to request a create form |
|
||||
| `body` | json | No | Record fields matching the account-specific NetSuite metadata schema |
|
||||
| `fields` | string | No | Comma-separated record fields to return |
|
||||
| `expand` | string | No | Comma-separated resources to expand when supported by the record metadata |
|
||||
| `expandSubResources` | boolean | No | Whether to expand sublists and subrecords in the response |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | NetSuite response body; record fields are account-specific and dynamic |
|
||||
|
||||
### NetSuite Get Select Options
|
||||
|
||||
Retrieve valid select values for one or more fields on a new or existing record.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `recordId` | string | No | Existing record ID; omit to evaluate options for a new record |
|
||||
| `fields` | string | Yes | Comma-separated select field IDs |
|
||||
| `q` | string | No | Optional select-option filter using CONTAIN, IS, or START_WITH |
|
||||
| `body` | json | No | Record fields matching the account-specific NetSuite metadata schema |
|
||||
| `limit` | number | No | Results to return in this page \(1-1000; default 100\) |
|
||||
| `offset` | number | No | Zero-based result offset; must be divisible by limit and stay within the first 100,000 results and 1,000 pages |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Select-options response keyed by requested field ID; each dynamic field contains an _selectOptions object with links, items, count, offset, hasMore, and totalResults |
|
||||
| ↳ `links` | array | Oracle HATEOAS links for the response |
|
||||
| ↳ `rel` | string | Link relationship |
|
||||
| ↳ `href` | string | Link target |
|
||||
|
||||
### NetSuite Attach Record or File
|
||||
|
||||
Attach a contact or file to another NetSuite record.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `recordId` | string | Yes | NetSuite internal ID or an external-ID reference beginning with eid: |
|
||||
| `relatedType` | string | Yes | Related resource type: contact or file |
|
||||
| `relatedId` | string | Yes | Internal ID, or external ID prefixed with eid:, of the contact or file |
|
||||
| `roleId` | string | No | Optional contact role internal ID |
|
||||
| `roleExternalId` | string | No | Optional contact role external ID |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Empty for the documented HTTP 204 No Content response |
|
||||
|
||||
### NetSuite Detach Record or File
|
||||
|
||||
Detach a contact or file from another NetSuite record.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `recordId` | string | Yes | NetSuite internal ID or an external-ID reference beginning with eid: |
|
||||
| `relatedType` | string | Yes | Related resource type: contact or file |
|
||||
| `relatedId` | string | Yes | Internal ID, or external ID prefixed with eid:, of the contact or file |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Empty for the documented HTTP 204 No Content response |
|
||||
|
||||
### NetSuite Execute Record Action
|
||||
|
||||
Execute a supported NetSuite record action such as approve, reject, or confirm.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `recordId` | string | Yes | NetSuite internal ID or an external-ID reference beginning with eid: |
|
||||
| `action` | string | Yes | NetSuite record action ID without the @ prefix |
|
||||
| `body` | json | No | Parameters accepted by the selected NetSuite record action |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Documented NetSuite record-action response |
|
||||
| ↳ `result` | boolean | True when NetSuite completed the record action |
|
||||
|
||||
### NetSuite Transform Record
|
||||
|
||||
Transform a supported source record into another NetSuite record type.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `recordId` | string | Yes | NetSuite internal ID or an external-ID reference beginning with eid: |
|
||||
| `targetRecordType` | string | Yes | Target record type supported by the source record metadata |
|
||||
| `body` | json | No | Record fields matching the account-specific NetSuite metadata schema |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Empty for the documented HTTP 204 No Content response |
|
||||
| `location` | string | URL of the transformed record, when NetSuite returns a Location header |
|
||||
|
||||
### NetSuite Batch Get Records
|
||||
|
||||
Submit an asynchronous request to retrieve up to 100 records of one type.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `ids` | string | Yes | Up to 100 comma-separated internal IDs or eid: external-ID references |
|
||||
| `fields` | string | No | Comma-separated record fields to return |
|
||||
| `expand` | string | No | Comma-separated resources to expand when supported by the record metadata |
|
||||
| `expandSubResources` | boolean | No | Whether to expand sublists and subrecords in the response |
|
||||
| `idempotencyKey` | string | No | Optional unique idempotency key for retrying the asynchronous request |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Empty for the documented HTTP 202 Accepted submission response |
|
||||
| `location` | string | Asynchronous job URL from the Location response header |
|
||||
| `jobId` | string | Asynchronous job ID parsed from the Location header |
|
||||
|
||||
### NetSuite Batch Create Records
|
||||
|
||||
Submit an asynchronous batch that creates up to 100 records of one type.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `items` | array | Yes | Array of 1-100 records matching the account-specific metadata schema |
|
||||
| `idempotencyKey` | string | No | Optional unique idempotency key for retrying the batch |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Empty for the documented HTTP 202 Accepted submission response |
|
||||
| `location` | string | Asynchronous job URL from the Location response header |
|
||||
| `jobId` | string | Asynchronous job ID parsed from the Location header |
|
||||
|
||||
### NetSuite Batch Update Records
|
||||
|
||||
Submit an asynchronous batch that updates up to 100 records of one type.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `items` | array | Yes | Array of 1-100 records; every item must include an internal or external ID |
|
||||
| `idempotencyKey` | string | No | Optional unique idempotency key for retrying the batch |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Empty for the documented HTTP 202 Accepted submission response |
|
||||
| `location` | string | Asynchronous job URL from the Location response header |
|
||||
| `jobId` | string | Asynchronous job ID parsed from the Location header |
|
||||
|
||||
### NetSuite Batch Upsert Records
|
||||
|
||||
Submit an asynchronous batch that creates or updates up to 100 records by external ID.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `items` | array | Yes | Array of 1-100 records; every item must include externalId |
|
||||
| `idempotencyKey` | string | No | Optional unique idempotency key for retrying the batch |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Empty for the documented HTTP 202 Accepted submission response |
|
||||
| `location` | string | Asynchronous job URL from the Location response header |
|
||||
| `jobId` | string | Asynchronous job ID parsed from the Location header |
|
||||
|
||||
### NetSuite Batch Delete Records
|
||||
|
||||
Submit an asynchronous request to delete up to 100 records of one type.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `ids` | string | Yes | Up to 100 comma-separated internal IDs or eid: external-ID references |
|
||||
| `idempotencyKey` | string | No | Optional unique idempotency key for retrying the batch |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Empty for the documented HTTP 202 Accepted submission response |
|
||||
| `location` | string | Asynchronous job URL from the Location response header |
|
||||
| `jobId` | string | Asynchronous job ID parsed from the Location header |
|
||||
|
||||
### NetSuite Execute SuiteQL
|
||||
|
||||
Execute one page of a SuiteQL query through SuiteTalk REST web services.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `query` | string | Yes | SuiteQL SELECT query; use a complete unique ORDER BY when retrieving multiple pages |
|
||||
| `limit` | number | No | Results to return in this page \(1-1000; default 100\) |
|
||||
| `offset` | number | No | Zero-based result offset; must be divisible by limit and stay within the first 100,000 results and 1,000 pages |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | One documented NetSuite collection page |
|
||||
| ↳ `links` | array | Oracle HATEOAS links for the response |
|
||||
| ↳ `rel` | string | Link relationship |
|
||||
| ↳ `href` | string | Link target |
|
||||
| ↳ `items` | array | Items in this page; item fields depend on the record, query, or dataset |
|
||||
| ↳ `count` | number | Number of items in this page |
|
||||
| ↳ `hasMore` | boolean | Whether another page is available |
|
||||
| ↳ `offset` | number | Offset of this page |
|
||||
| ↳ `totalResults` | number | Total number of matching items |
|
||||
|
||||
### NetSuite List SuiteAnalytics Datasets
|
||||
|
||||
List one page of SuiteAnalytics Workbook datasets available to the authenticated role.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `limit` | number | No | Results to return in this page \(1-1000; default 100\) |
|
||||
| `offset` | number | No | Zero-based result offset; must be divisible by limit and stay within the first 100,000 results and 1,000 pages |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | One documented NetSuite collection page |
|
||||
| ↳ `links` | array | Oracle HATEOAS links for the response |
|
||||
| ↳ `rel` | string | Link relationship |
|
||||
| ↳ `href` | string | Link target |
|
||||
| ↳ `items` | array | Items in this page; item fields depend on the record, query, or dataset |
|
||||
| ↳ `count` | number | Number of items in this page |
|
||||
| ↳ `hasMore` | boolean | Whether another page is available |
|
||||
| ↳ `offset` | number | Offset of this page |
|
||||
| ↳ `totalResults` | number | Total number of matching items |
|
||||
|
||||
### NetSuite Execute SuiteAnalytics Dataset
|
||||
|
||||
Execute one page of a standard or custom SuiteAnalytics Workbook dataset.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `datasetId` | string | Yes | SuiteAnalytics dataset script ID |
|
||||
| `limit` | number | No | Results to return in this page \(1-1000; default 100\) |
|
||||
| `offset` | number | No | Zero-based result offset; must be divisible by limit and stay within the first 100,000 results and 1,000 pages |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | One documented NetSuite collection page |
|
||||
| ↳ `links` | array | Oracle HATEOAS links for the response |
|
||||
| ↳ `rel` | string | Link relationship |
|
||||
| ↳ `href` | string | Link target |
|
||||
| ↳ `items` | array | Items in this page; item fields depend on the record, query, or dataset |
|
||||
| ↳ `count` | number | Number of items in this page |
|
||||
| ↳ `hasMore` | boolean | Whether another page is available |
|
||||
| ↳ `offset` | number | Offset of this page |
|
||||
| ↳ `totalResults` | number | Total number of matching items |
|
||||
|
||||
### NetSuite List Record Types
|
||||
|
||||
List record types exposed to the authenticated role by the REST metadata catalog.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | NetSuite REST metadata catalog |
|
||||
| ↳ `links` | array | Oracle HATEOAS links for the response |
|
||||
| ↳ `rel` | string | Link relationship |
|
||||
| ↳ `href` | string | Link target |
|
||||
| ↳ `items` | array | Record types exposed to the authenticated role |
|
||||
| ↳ `name` | string | REST record type script ID |
|
||||
| ↳ `links` | array | Oracle HATEOAS links for the response |
|
||||
| ↳ `rel` | string | Link relationship |
|
||||
| ↳ `href` | string | Link target |
|
||||
| ↳ `mediaType` | string | Media type advertised for the linked metadata resource |
|
||||
|
||||
### NetSuite Get Record Metadata
|
||||
|
||||
Retrieve account-specific metadata for one NetSuite record type.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
|
||||
| `format` | string | No | Metadata representation: default, openapi, or json_schema |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | NetSuite response body; record fields are account-specific and dynamic |
|
||||
|
||||
### NetSuite Get Async Status
|
||||
|
||||
Retrieve job status, list job tasks, or retrieve one task status.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `jobId` | string | Yes | Asynchronous job ID |
|
||||
| `view` | string | No | Retrieve job status, list tasks for the job, or retrieve one task status |
|
||||
| `taskId` | string | No | Task ID; required when view is task |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Documented NetSuite asynchronous job, task collection, or task status |
|
||||
| ↳ `completed` | boolean | Whether processing has completed |
|
||||
| ↳ `endTime` | string | Task completion time |
|
||||
| ↳ `id` | string | Asynchronous job or task ID |
|
||||
| ↳ `progress` | string | Current task progress state |
|
||||
| ↳ `startTime` | string | Task start time |
|
||||
| ↳ `count` | number | Number of task collection entries returned |
|
||||
| ↳ `items` | array | Collection entries containing links to one or more asynchronous tasks |
|
||||
| ↳ `links` | array | Links to individual tasks |
|
||||
| ↳ `rel` | string | Link relationship |
|
||||
| ↳ `href` | string | Link target |
|
||||
| ↳ `links` | array | HATEOAS links for the job or task collection |
|
||||
| ↳ `rel` | string | Link relationship |
|
||||
| ↳ `href` | string | Link target |
|
||||
| ↳ `task` | object | Link container for the tasks belonging to this asynchronous job |
|
||||
| ↳ `links` | array | Links to the job task collection |
|
||||
| ↳ `rel` | string | Link relationship |
|
||||
| ↳ `href` | string | Link target |
|
||||
|
||||
### NetSuite Get Async Operation Result
|
||||
|
||||
Retrieve the provider response for one task within a completed asynchronous job.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `jobId` | string | Yes | Asynchronous job ID |
|
||||
| `taskId` | string | Yes | Task ID within the asynchronous job |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Result payload for the submitted asynchronous operation; record fields are account-specific and dynamic |
|
||||
|
||||
### NetSuite Get Server Time
|
||||
|
||||
Retrieve the current UTC time from the NetSuite server.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | NetSuite server time response |
|
||||
| ↳ `serverTime` | string | Current NetSuite server time in UTC |
|
||||
|
||||
### NetSuite Get Governance Limits
|
||||
|
||||
Retrieve REST web-services concurrency limits for the NetSuite account and integration; NetSuite requires an Administrator role.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | number | HTTP status returned by NetSuite |
|
||||
| `data` | json | Documented NetSuite governance limits |
|
||||
| ↳ `accountConcurrencyLimit` | number | Account concurrency limit |
|
||||
| ↳ `accountUnallocatedConcurrencyLimit` | number | Account concurrency not allocated to integrations |
|
||||
| ↳ `integrationConcurrencyLimit` | number | Concurrency allocated to this integration |
|
||||
| ↳ `integrationLimitType` | string | Limit assignment: integrationSpecific, accountLimit, or internal |
|
||||
@@ -0,0 +1,564 @@
|
||||
---
|
||||
title: RabbitMQ
|
||||
description: Publish and read messages and manage queues in RabbitMQ
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="rabbitmq"
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[RabbitMQ](https://www.rabbitmq.com/) is an open-source message broker that sits between the parts of a system that produce work and the parts that do it. A producer publishes a message to an **exchange**, the exchange matches the message's **routing key** against its **bindings**, and every matching **queue** holds the message until a consumer takes it. That indirection is the point: producers never need to know who consumes their messages, and a queue absorbs bursts that would otherwise overwhelm a downstream service.
|
||||
|
||||
**Why RabbitMQ?**
|
||||
- **Durable buffering:** A queue holds work while consumers are slow, restarting, or offline, so a traffic spike becomes a backlog to work through rather than dropped requests.
|
||||
- **Flexible routing:** Direct exchanges route on an exact key, topic exchanges on wildcard patterns like `orders.*`, fanout exchanges to every bound queue, and headers exchanges on message metadata.
|
||||
- **Delivery guarantees:** Messages and queues can be marked durable so they survive a broker restart, and unacknowledged messages return to the queue when a consumer dies mid-work.
|
||||
- **Failure handling built in:** Dead-letter exchanges, per-message TTLs, and queue length limits let you decide up front what happens to work that expires, overflows, or repeatedly fails.
|
||||
- **Runs anywhere:** Self-hosted on your own infrastructure, or managed through providers such as CloudAMQP and Amazon MQ.
|
||||
|
||||
**Using RabbitMQ in Sim**
|
||||
|
||||
Sim talks to RabbitMQ over its **Management HTTP API** — the same interface behind the RabbitMQ management UI — using the management plugin's base URL plus a username and password. There is no AMQP connection to configure and no client library to install; if you can reach the management UI in a browser, Sim can reach your broker.
|
||||
|
||||
**Key benefits of using RabbitMQ in Sim:**
|
||||
- **Publish from any workflow step:** Hand off enriched or classified data to an existing service by publishing to an exchange, without that service needing to know Sim exists.
|
||||
- **Inspect queues without consuming them:** The default acknowledgement mode requeues what it reads, so an agent can examine a dead-letter backlog and leave the messages exactly where they were.
|
||||
- **Triage failures with an agent:** Read a dead-letter queue, let an agent group messages by root cause, and route the summary to Slack, PagerDuty, or a table.
|
||||
- **Monitor broker health on a schedule:** List queues and read the broker overview to catch a queue whose depth is climbing or that has lost all its consumers.
|
||||
- **Declare topology as part of a workflow:** Create queues, set arguments such as quorum type or dead-lettering, and bind them to exchanges as an automated setup step.
|
||||
|
||||
**Before you start**
|
||||
|
||||
- The **management plugin must be enabled and reachable** from Sim. It listens on port `15672` by default and is separate from the AMQP port (`5672`). Self-hosted brokers enable it with `rabbitmq-plugins enable rabbitmq_management`; managed providers expose it as a management or console URL.
|
||||
- The management URL **must use `https`** unless the broker is on a loopback host. Credentials travel on every request as HTTP basic auth, so plain `http` to a remote broker would put them on the wire in the clear — Sim rejects it rather than sending them.
|
||||
- The user you authenticate as needs the **`management` tag** at minimum, plus read and write permissions on the virtual host you target. Administrative operations require broader permissions.
|
||||
- Publishing and reading messages over the HTTP API is **convenient but not a high-throughput transport** — RabbitMQ opens a new connection per request. It is well suited to workflow-rate traffic, inspection, and operational automation; a service consuming thousands of messages per second should use an AMQP client instead.
|
||||
- Queue statistics such as message and consumer counts are **collected on an interval**, so a queue declared moments ago may report them as empty until the broker's next sample.
|
||||
- Reading messages is **bounded per call** so one retrieval cannot exceed Sim's response limit. A batch is capped at 50 messages, payloads are truncated (each message reports whether it was), and a large batch shortens payloads further. AMQP properties and headers are returned in full — the broker offers no way to truncate them — so retrieving several messages carrying very large headers may still hit the limit; lower the count if that happens.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Connect agents to a RabbitMQ broker through its Management HTTP API. Publish messages to exchanges, read messages off queues, declare queues, exchanges, bindings, and policies, and inspect broker health, queue depth, consumers, connections, and cluster nodes. Works with self-hosted brokers and managed offerings such as CloudAMQP as long as the management plugin is reachable.
|
||||
|
||||
|
||||
|
||||
## Actions
|
||||
|
||||
### RabbitMQ Publish Message
|
||||
|
||||
Publish a message to a RabbitMQ exchange with a routing key. Reports whether the message was routed to at least one queue.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `exchange` | string | No | Exchange to publish to. Leave empty to publish to the default exchange, which routes by queue name. Empty is a valid value, so this is not required. |
|
||||
| `routingKey` | string | Yes | Routing key. When publishing to the default exchange this is the target queue name. |
|
||||
| `payload` | string | Yes | Message body to publish |
|
||||
| `payloadEncoding` | string | No | How the payload is encoded: string \(default\) or base64 |
|
||||
| `properties` | string | No | AMQP basic properties as a JSON object, e.g. \{"delivery_mode":2,"content_type":"application/json"\} |
|
||||
| `headers` | string | No | Message headers as a JSON object, e.g. \{"source":"sim"\} |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `routed` | boolean | Whether the message was routed to at least one queue. False means no binding matched and the message was dropped. |
|
||||
| `exchange` | string | Exchange the message was published to |
|
||||
| `routingKey` | string | Routing key the message was published with |
|
||||
|
||||
### RabbitMQ Get Messages
|
||||
|
||||
Retrieve messages from a RabbitMQ queue. Defaults to requeueing the messages so they stay available to real consumers.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `queue` | string | Yes | Queue to read messages from |
|
||||
| `count` | number | No | Maximum number of messages to retrieve, from 1 to $\{MAX_MESSAGE_COUNT\}. Defaults to 1 |
|
||||
| `ackmode` | string | No | How retrieved messages are handled: ack_requeue_true \(default, leaves messages in the queue\), ack_requeue_false \(removes them\), reject_requeue_true, or reject_requeue_false |
|
||||
| `encoding` | string | No | auto \(default\) returns readable text where possible, base64 always returns base64 |
|
||||
| `truncate` | number | No | Truncate payloads longer than this many bytes. Defaults to $\{DEFAULT_TRUNCATE_BYTES\}, capped at $\{MAX_TRUNCATE_BYTES\}, and lowered further at high counts so the whole batch stays inside the response limit. Each message reports whether it was truncated |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `queueName` | string | Queue the messages were read from |
|
||||
| `count` | number | Number of messages retrieved |
|
||||
| `messages` | array | Retrieved messages, empty when the queue holds nothing |
|
||||
|
||||
### RabbitMQ List Queues
|
||||
|
||||
List queues in a RabbitMQ virtual host with their depth, consumer count, and configuration.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `page` | number | No | Page of results to return, starting at 1 |
|
||||
| `pageSize` | number | No | Queues per page, from 1 to $\{RABBITMQ_MAX_PAGE_SIZE\}. Defaults to $\{DEFAULT_PAGE_SIZE\} |
|
||||
| `name` | string | No | Filter queues whose name contains this value |
|
||||
| `useRegex` | boolean | No | Treat the name filter as a regular expression |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `queues` | array | Queues in the virtual host |
|
||||
| `count` | number | Number of queues returned on this page |
|
||||
| `totalCount` | number | Total queues in the virtual host before filtering |
|
||||
| `page` | number | Page number returned |
|
||||
| `pageCount` | number | Total number of pages |
|
||||
|
||||
### RabbitMQ Get Queue
|
||||
|
||||
Read a single RabbitMQ queue, including its depth, consumer count, and declaration settings.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `queue` | string | Yes | Queue name to read |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `queue` | object | The requested queue |
|
||||
|
||||
### RabbitMQ Create Queue
|
||||
|
||||
Declare a RabbitMQ queue. Declaring a queue that already exists with the same settings succeeds without changing it.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `queue` | string | Yes | Name of the queue to declare |
|
||||
| `durable` | boolean | No | Whether the queue survives a broker restart. Defaults to true |
|
||||
| `autoDelete` | boolean | No | Delete the queue when its last consumer disconnects. Defaults to false |
|
||||
| `arguments` | string | No | Queue arguments as a JSON object, e.g. \{"x-queue-type":"quorum","x-message-ttl":60000\} |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `queueName` | string | Name of the declared queue |
|
||||
| `vhost` | string | Virtual host the queue was declared in |
|
||||
| `created` | boolean | Whether the declaration succeeded |
|
||||
|
||||
### RabbitMQ Delete Queue
|
||||
|
||||
Delete a RabbitMQ queue and every message still in it. Can be guarded so the delete only happens when the queue is unused or empty.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `queue` | string | Yes | Name of the queue to delete |
|
||||
| `ifUnused` | boolean | No | Only delete the queue when it has no consumers |
|
||||
| `ifEmpty` | boolean | No | Only delete the queue when it holds no messages |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `queueName` | string | Name of the deleted queue |
|
||||
| `vhost` | string | Virtual host the queue was deleted from |
|
||||
| `deleted` | boolean | Whether the queue was deleted |
|
||||
|
||||
### RabbitMQ Purge Queue
|
||||
|
||||
Discard every ready message in a RabbitMQ queue while leaving the queue itself in place.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `queue` | string | Yes | Name of the queue to purge |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `queueName` | string | Name of the purged queue |
|
||||
| `vhost` | string | Virtual host the queue belongs to |
|
||||
| `purged` | boolean | Whether the queue was purged |
|
||||
|
||||
### RabbitMQ List Exchanges
|
||||
|
||||
List exchanges in a RabbitMQ virtual host with their type and declaration settings.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `page` | number | No | Page of results to return, starting at 1 |
|
||||
| `pageSize` | number | No | Exchanges per page, from 1 to $\{RABBITMQ_MAX_PAGE_SIZE\}. Defaults to $\{DEFAULT_PAGE_SIZE\} |
|
||||
| `name` | string | No | Filter exchanges whose name contains this value |
|
||||
| `useRegex` | boolean | No | Treat the name filter as a regular expression |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `exchanges` | array | Exchanges in the virtual host |
|
||||
| `count` | number | Number of exchanges returned on this page |
|
||||
| `totalCount` | number | Total exchanges in the virtual host before filtering |
|
||||
| `page` | number | Page number returned |
|
||||
| `pageCount` | number | Total number of pages |
|
||||
|
||||
### RabbitMQ Get Exchange
|
||||
|
||||
Read a single RabbitMQ exchange and the settings it was declared with.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `exchange` | string | No | Exchange name to read. Leave empty for the default exchange, which is a valid value, so this is not required |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `exchange` | object | The requested exchange |
|
||||
|
||||
### RabbitMQ Create Exchange
|
||||
|
||||
Declare a RabbitMQ exchange. Declaring an exchange that already exists with the same settings succeeds without changing it.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `exchange` | string | Yes | Name of the exchange to declare |
|
||||
| `exchangeType` | string | No | Routing behaviour: direct \(exact routing key, default\), topic \(wildcard patterns\), fanout \(every bound queue\), or headers \(match on binding arguments\) |
|
||||
| `durable` | boolean | No | Whether the exchange survives a broker restart. Defaults to true |
|
||||
| `autoDelete` | boolean | No | Delete the exchange once its last binding is removed. Defaults to false |
|
||||
| `internal` | boolean | No | Internal exchanges cannot be published to directly, only bound from another exchange. Defaults to false |
|
||||
| `arguments` | string | No | Exchange arguments as a JSON object, e.g. \{"alternate-exchange":"unrouted"\} to capture messages that match no binding |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `exchangeName` | string | Name of the declared exchange |
|
||||
| `vhost` | string | Virtual host the exchange was declared in |
|
||||
| `created` | boolean | Whether the declaration succeeded |
|
||||
|
||||
### RabbitMQ Delete Exchange
|
||||
|
||||
Delete a RabbitMQ exchange and every binding attached to it. Publishers targeting it will fail afterwards.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `exchange` | string | Yes | Name of the exchange to delete |
|
||||
| `ifUnused` | boolean | No | Only delete the exchange when nothing is bound to it |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `exchangeName` | string | Name of the deleted exchange |
|
||||
| `vhost` | string | Virtual host the exchange was deleted from |
|
||||
| `deleted` | boolean | Whether the exchange was deleted |
|
||||
|
||||
### RabbitMQ List Bindings
|
||||
|
||||
List the bindings that route messages into a RabbitMQ queue, including the implicit default-exchange binding.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `queue` | string | Yes | Queue whose bindings should be listed |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `queueName` | string | Queue the bindings route into |
|
||||
| `bindings` | array | Bindings targeting the queue. The entry with an empty source is the implicit default-exchange binding |
|
||||
| `count` | number | Number of bindings returned |
|
||||
|
||||
### RabbitMQ List Exchange Bindings
|
||||
|
||||
List everything an exchange routes to, so you can see which routing keys reach which queues.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `exchange` | string | No | Exchange whose outgoing bindings should be listed. Leave empty for the default exchange, which is a valid value, so this is not required |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `exchangeName` | string | Exchange the bindings originate from |
|
||||
| `bindings` | array | Bindings routing out of the exchange. An empty list means nothing it publishes can be delivered |
|
||||
| `count` | number | Number of bindings returned |
|
||||
|
||||
### RabbitMQ Create Binding
|
||||
|
||||
Bind a queue or another exchange to a RabbitMQ exchange so messages matching a routing key are routed to it.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `exchange` | string | Yes | Source exchange to bind from |
|
||||
| `queue` | string | Yes | Destination queue, or destination exchange when binding exchange to exchange |
|
||||
| `destinationType` | string | No | Whether the destination is a queue \(default\) or an exchange. Exchange-to-exchange bindings chain routing between exchanges |
|
||||
| `routingKey` | string | No | Routing key the binding matches. Topic exchanges accept wildcards such as orders.* |
|
||||
| `arguments` | string | No | Binding arguments as a JSON object. Headers exchanges match on these, e.g. \{"x-match":"all","type":"invoice"\} |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `exchange` | string | Source exchange the binding reads from |
|
||||
| `queueName` | string | Destination queue the binding routes into |
|
||||
| `routingKey` | string | Routing key the binding matches |
|
||||
| `propertiesKey` | string | Broker identifier addressing the new binding |
|
||||
| `created` | boolean | Whether the binding was created |
|
||||
|
||||
### RabbitMQ Delete Binding
|
||||
|
||||
Remove a binding so an exchange stops routing its matching messages to that destination.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `exchange` | string | Yes | Source exchange the binding reads from |
|
||||
| `destination` | string | Yes | Destination queue or exchange the binding routes to |
|
||||
| `destinationType` | string | No | Whether the destination is a queue \(default\) or an exchange |
|
||||
| `propertiesKey` | string | Yes | Broker identifier for the binding, taken from List Bindings or Create Binding. It is the routing key for a simple binding, ~ for an empty routing key, and a hashed value when the binding has arguments |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `exchange` | string | Source exchange the binding read from |
|
||||
| `destination` | string | Destination the binding routed to |
|
||||
| `propertiesKey` | string | Broker identifier of the deleted binding |
|
||||
| `deleted` | boolean | Whether the binding was deleted |
|
||||
|
||||
### RabbitMQ Get Overview
|
||||
|
||||
Read broker-wide RabbitMQ status: version, cluster name, object totals, queue depth totals, and message rates.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `rabbitmqVersion` | string | RabbitMQ version running on the node |
|
||||
| `productName` | string | Broker product name |
|
||||
| `productVersion` | string | Broker product version |
|
||||
| `erlangVersion` | string | Erlang runtime version |
|
||||
| `clusterName` | string | Name of the cluster |
|
||||
| `node` | string | Node that served the request |
|
||||
| `objectTotals` | object | Counts of brokers objects |
|
||||
| ↳ `connections` | number | Open connections |
|
||||
| ↳ `channels` | number | Open channels |
|
||||
| ↳ `exchanges` | number | Declared exchanges |
|
||||
| ↳ `queues` | number | Declared queues |
|
||||
| ↳ `consumers` | number | Registered consumers |
|
||||
| `queueTotals` | object | Aggregate queue depth across the broker |
|
||||
| ↳ `messages` | number | Total messages across all queues |
|
||||
| ↳ `messages_ready` | number | Messages ready for delivery |
|
||||
| ↳ `messages_unacknowledged` | number | Delivered but unacknowledged messages |
|
||||
| `messageStats` | json | Broker-wide message counters and rates, e.g. publish and confirm totals |
|
||||
|
||||
### RabbitMQ Health Check
|
||||
|
||||
Run one of the broker health checks and report whether it passed. A failing check is a normal result, not a tool error.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `check` | string | No | Which check to run: alarms \(cluster-wide resource alarms, default\), local-alarms, virtual-hosts, node-is-quorum-critical, port-listener, protocol-listener, or certificate-expiration |
|
||||
| `port` | number | No | Port to verify a listener on. Required for the port-listener check |
|
||||
| `protocol` | string | No | Protocol to verify a listener for, e.g. amqp, amqp/ssl, mqtt, stomp, or http. Required for the protocol-listener check |
|
||||
| `within` | number | No | How far ahead to look for expiring certificates. Required for the certificate-expiration check |
|
||||
| `unit` | string | No | Unit for the certificate-expiration window: days, weeks, months \(default\), or years |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `check` | string | The health check that was run |
|
||||
| `healthy` | boolean | True when the check reported status ok |
|
||||
| `status` | string | Raw status reported by the broker: ok or failed |
|
||||
| `reason` | string | Explanation the broker gave, present on failures and on some passes |
|
||||
| `details` | json | Full check body, including check-specific fields such as the ports or protocols found |
|
||||
|
||||
### RabbitMQ List Nodes
|
||||
|
||||
List the cluster nodes with memory, disk, file-descriptor, and alarm state. A fired alarm blocks publishers broker-wide.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `nodes` | array | Cluster nodes and their resource headroom |
|
||||
| `count` | number | Number of nodes in the cluster |
|
||||
|
||||
### RabbitMQ List Virtual Hosts
|
||||
|
||||
List the virtual hosts on the broker with their message totals, so you can discover which scopes exist.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `vhosts` | array | Virtual hosts the authenticated user can see |
|
||||
| `count` | number | Number of virtual hosts returned |
|
||||
|
||||
### RabbitMQ List Connections
|
||||
|
||||
List client connections to the broker with their user, state, and channel count. Connections are cluster-wide, not scoped to one virtual host.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `page` | number | No | Page of results to return, starting at 1 |
|
||||
| `pageSize` | number | No | Connections per page, from 1 to $\{RABBITMQ_MAX_PAGE_SIZE\}. Defaults to $\{DEFAULT_PAGE_SIZE\} |
|
||||
| `name` | string | No | Filter connections whose name contains this value |
|
||||
| `useRegex` | boolean | No | Treat the name filter as a regular expression |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `connections` | array | Open client connections |
|
||||
| `count` | number | Number of connections returned on this page |
|
||||
| `totalCount` | number | Total connections before filtering |
|
||||
| `page` | number | Page number returned |
|
||||
| `pageCount` | number | Total number of pages |
|
||||
|
||||
### RabbitMQ List Channels
|
||||
|
||||
List open channels with their prefetch limit and unacknowledged message count, which is where stalled consumers show up. Channels are cluster-wide, not scoped to one virtual host.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `page` | number | No | Page of results to return, starting at 1 |
|
||||
| `pageSize` | number | No | Channels per page, from 1 to $\{RABBITMQ_MAX_PAGE_SIZE\}. Defaults to $\{DEFAULT_PAGE_SIZE\} |
|
||||
| `name` | string | No | Filter channels whose name contains this value |
|
||||
| `useRegex` | boolean | No | Treat the name filter as a regular expression |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `channels` | array | Open channels |
|
||||
| `count` | number | Number of channels returned on this page |
|
||||
| `totalCount` | number | Total channels before filtering |
|
||||
| `page` | number | Page number returned |
|
||||
| `pageCount` | number | Total number of pages |
|
||||
|
||||
### RabbitMQ List Consumers
|
||||
|
||||
List the consumers subscribed in a virtual host. An empty result for a queue with a backlog means nothing is processing it.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `consumers` | array | Consumers currently subscribed in the virtual host |
|
||||
| `count` | number | Number of consumers returned |
|
||||
|
||||
### RabbitMQ List Policies
|
||||
|
||||
List the policies in a virtual host. Policies are how dead-lettering, TTLs, and length limits get applied to matching queues and exchanges.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `policies` | array | Policies defined in the virtual host |
|
||||
| `count` | number | Number of policies returned |
|
||||
|
||||
### RabbitMQ Create Policy
|
||||
|
||||
Create or replace a RabbitMQ policy, applying settings such as dead-lettering, TTLs, or length limits to every queue or exchange whose name matches a pattern.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `policyName` | string | Yes | Name of the policy. Reusing an existing name replaces that policy |
|
||||
| `pattern` | string | Yes | Regular expression matched against queue or exchange names, e.g. ^orders\\. to match every name starting with orders. |
|
||||
| `definition` | string | Yes | Settings to apply, as a JSON object, e.g. \{"dead-letter-exchange":"dlx","message-ttl":86400000,"max-length":10000\} |
|
||||
| `priority` | number | No | Priority, defaulting to 0. When several policies match a resource only the highest-priority one applies — they do not merge |
|
||||
| `applyTo` | string | No | What the policy applies to: queues \(default\), classic_queues, quorum_queues, streams, exchanges, or all |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `policyName` | string | Name of the created policy |
|
||||
| `vhost` | string | Virtual host the policy applies in |
|
||||
| `created` | boolean | Whether the policy was created or replaced |
|
||||
|
||||
### RabbitMQ Delete Policy
|
||||
|
||||
Delete a RabbitMQ policy. Every queue and exchange it matched immediately loses the settings it applied.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `policyName` | string | Yes | Name of the policy to delete |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `policyName` | string | Name of the deleted policy |
|
||||
| `vhost` | string | Virtual host the policy applied in |
|
||||
| `deleted` | boolean | Whether the policy was deleted |
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ Tables are created from the **Tables** section in the sidebar. Each table requir
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Create and manage custom data tables. Store, query, and manipulate structured data within workflows.
|
||||
Create and manage custom data tables. Store, query, and manipulate structured data within workflows. Query Rows returns every matching row when Limit is omitted and fails if the result exceeds 5MB.
|
||||
|
||||
|
||||
|
||||
@@ -213,7 +213,7 @@ Query rows from a table with filtering, sorting, and pagination
|
||||
| `tableId` | string | Yes | Table ID |
|
||||
| `filter` | object | No | Filter conditions \(MongoDB-style operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $contains, $ncontains, $startsWith, $endsWith, $empty\) |
|
||||
| `sort` | object | No | Sort order as \{field: "asc"\|"desc"\} |
|
||||
| `limit` | number | No | Maximum rows to return \(default: $\{TABLE_LIMITS.DEFAULT_QUERY_LIMIT\}, max: $\{TABLE_LIMITS.MAX_QUERY_LIMIT\}\) |
|
||||
| `limit` | number | No | Maximum rows to return. Omit to return every matching row; the query fails if the result exceeds the 5MB response budget. |
|
||||
| `offset` | number | No | Number of rows to skip \(default: 0\) |
|
||||
|
||||
#### Output
|
||||
@@ -226,6 +226,7 @@ Query rows from a table with filtering, sorting, and pagination
|
||||
| `totalCount` | number | Total rows matching filter |
|
||||
| `limit` | number | Limit used in query |
|
||||
| `offset` | number | Offset used in query |
|
||||
| `nextCursor` | string | Non-null when more rows match past this page. A page can end early at the byte budget, so this — not a short rowCount — is what says whether more remain. To page, advance offset by rowCount and stop when this is null. |
|
||||
|
||||
### Get Row
|
||||
|
||||
|
||||
@@ -14,23 +14,24 @@ Connectors continuously sync documents from external services into your knowledg
|
||||
|
||||
<Image src="/static/connectors/connectors-sources.png" alt="Connect Source picker showing a searchable list of available connectors including Airtable, Asana, Confluence, Discord, Dropbox, Evernote, Fireflies, GitHub, and Gmail" width={800} height={500} />
|
||||
|
||||
Sim ships with 49 built-in connectors:
|
||||
Sim ships with 61 built-in connectors:
|
||||
|
||||
| Category | Connectors |
|
||||
|----------|-----------|
|
||||
| **Productivity** | Notion, Confluence, Asana, Linear, Jira, Jira Service Management, Monday, Google Calendar, Google Sheets, Google Forms, Typeform |
|
||||
| **Cloud Storage** | Google Drive, Dropbox, OneDrive, SharePoint, Amazon S3 |
|
||||
| **Documents** | Google Docs, WordPress, Webflow, DocuSign |
|
||||
| **Productivity** | Notion, Confluence, Asana, Linear, Jira, Jira Service Management, Monday, Trello, ClickUp, Google Calendar, Google Sheets, Google Forms, Microsoft Excel, Typeform |
|
||||
| **Cloud Storage** | Google Drive, Dropbox, OneDrive, SharePoint, Box, Amazon S3, SFTP |
|
||||
| **Documents** | Google Docs, Google Slides, Mintlify, WordPress, Webflow, DocuSign |
|
||||
| **Development** | GitHub, GitLab, Azure DevOps, Sentry |
|
||||
| **Communication** | Slack, Discord, Microsoft Teams, Reddit, YouTube |
|
||||
| **Communication** | Slack, Discord, Microsoft Teams, Reddit, X, YouTube |
|
||||
| **Email** | Gmail, Outlook |
|
||||
| **CRM** | HubSpot, Salesforce |
|
||||
| **Support** | Intercom, ServiceNow, Zendesk |
|
||||
| **Incident Management** | incident.io, Rootly |
|
||||
| **Support** | Intercom, ServiceNow, Zendesk, Zoho Desk |
|
||||
| **Incident Management** | incident.io, Rootly, PagerDuty |
|
||||
| **Data** | Airtable |
|
||||
| **Note-taking** | Evernote, Obsidian |
|
||||
| **Meetings** | Zoom, Gong, Grain, Granola, Fathom, Fireflies |
|
||||
| **Meetings** | Zoom, Google Meet, Gong, Grain, Granola, Fathom, Fireflies |
|
||||
| **Recruiting** | Greenhouse, Ashby |
|
||||
| **Compliance** | Google Vault |
|
||||
|
||||
## Adding a Connector
|
||||
|
||||
@@ -55,6 +56,9 @@ Other connectors use **API keys** or **personal access tokens** instead. The set
|
||||
| **YouTube** | YouTube Data API key from the Google Cloud Console |
|
||||
| **Amazon S3** | Secret Access Key (the Access Key ID, region, and bucket are entered as config fields) |
|
||||
| **Sentry** | Auth token with `project:read` and `event:read` scopes |
|
||||
| **PagerDuty** | REST API key from Integrations → API Access Keys |
|
||||
| **SFTP** | Password or unencrypted private key (host, port, username, and root path are entered as config fields) |
|
||||
| **Mintlify** | API key — optional for public documentation sites, which sync from `llms.txt` |
|
||||
|
||||
<Callout type="info">
|
||||
If you rotate an API key in the external service, update it in Sim as well — OAuth tokens refresh automatically, but API keys do not.
|
||||
|
||||
@@ -124,8 +124,16 @@ When a workspace secret and a personal secret share the same key name, the **wor
|
||||
|
||||
When a workflow runs, secrets resolve in this order:
|
||||
|
||||
1. **Workspace secrets** are checked first
|
||||
2. **Personal secrets** are used as a fallback — from the user who triggered the run (manual) or the workflow owner (automated runs via API, webhook, or schedule)
|
||||
1. **Workspace secrets** are checked first, and always resolve against the identity running the workflow — the caller when one can be identified, otherwise the workspace's billing account. A run only sees the workspace secrets that identity is allowed to use.
|
||||
2. **Personal secrets** are used as a fallback, from whichever identity is running:
|
||||
|
||||
| Run started by | Personal secrets come from |
|
||||
| --- | --- |
|
||||
| Clicking Run, or a personal API key | The person running it |
|
||||
| A workspace API key, schedule, or webhook | The workflow owner |
|
||||
| A public API URL with no authentication | Nobody — personal secrets do not resolve |
|
||||
|
||||
The workflow owner is the fallback only where nobody can be identified but somebody in the workspace set the trigger up, since those workflows are usually built against the owner's own keys. A public URL can be called by anyone, so it never borrows a person's keys at all — put every secret such a workflow needs in **Workspace**.
|
||||
|
||||
## Best Practices
|
||||
|
||||
@@ -138,7 +146,7 @@ When a workflow runs, secrets resolve in this order:
|
||||
{ question: "Are my secrets encrypted at rest?", answer: "Yes. Values saved under Secrets are encrypted before being stored in the database." },
|
||||
{ question: "Can a saved secret still appear in a workflow result?", answer: "Yes. Functional workflow data is not rewritten, so the raw value can still reach downstream blocks and tools and can appear in workflow execution responses, streams, or callbacks if your workflow deliberately returns or prints it. Log-facing views and read APIs receive a protected copy after a successful {{KEY}} resolution. Before content is sent to a model, exact values from the run's authorized secret catalog are replaced with placeholders, but encoded or otherwise transformed values remain outside that protection." },
|
||||
{ question: "What happens if both a workspace secret and a personal secret have the same key name?", answer: "Among secrets available to the execution actor, the workspace secret takes precedence and the personal secret is the fallback. An inaccessible workspace secret does not shadow an authorized personal value." },
|
||||
{ question: "Who determines which personal secret is used for automated runs?", answer: "For manual runs, the personal secrets of the user who clicked Run are used as fallback. For automated runs triggered by API, webhook, or schedule, the personal secrets of the workflow owner are used instead." },
|
||||
{ question: "Who determines which personal secret is used for automated runs?", answer: "Whoever is running it, when that can be identified. Clicking Run or calling with a personal API key uses that person's personal secrets. A workspace API key, schedule, or webhook has no identifiable caller, so it falls back to the workflow owner's — those triggers are set up inside the workspace and the workflow is usually built against the owner's own keys. A public API URL with no authentication can be called by anyone, so no personal secrets resolve at all — those workflows run on workspace secrets only." },
|
||||
{ question: "Can I import secrets from a .env file?", answer: "Yes. Paste .env-style content (KEY=VALUE format) into any key or value field and the secrets will be auto-populated. The parser supports export KEY=VALUE, quoted values, and inline comments." },
|
||||
{ question: "What happens if I delete a secret that is used in a workflow?", answer: "The workflow will fail at any block that references the deleted secret during execution because the value cannot be resolved. Update any references before deleting a secret." },
|
||||
]} />
|
||||
|
||||
@@ -125,7 +125,7 @@ After the run, the table holds the enriched rows. The next run queries them agai
|
||||
|
||||
**Iterate row by row.** Wrap a Query → process → update cycle in a [Loop block](/workflows/blocks/loop) to handle one row at a time. This runs sequentially, slower than a batch update but useful when each row needs its own multi-step logic. Inside the loop the Agent reads the current row and an Update Row by ID writes its result.
|
||||
|
||||
**Paginate large reads.** Query Rows returns at most 1000 rows. When `totalCount` exceeds your **Limit**, increase **Offset** on each pass (0, then 100, then 200) to walk through the whole table, typically inside a Loop.
|
||||
**Paginate large reads.** Query Rows returns at most 1000 rows, and a page can also end early once its rows reach the response size budget — so a page may come back shorter than your **Limit** even when more rows match. Advance **Offset** by the `rowCount` you actually received, not by the Limit you asked for, and keep going while `nextCursor` is set. Stop when `nextCursor` is null. Stepping by the Limit instead skips whatever a short page left behind.
|
||||
|
||||
## Inspecting reads and writes
|
||||
|
||||
|
||||
@@ -31,6 +31,16 @@ Reference an earlier output inside an expression with a [connection tag](/workfl
|
||||
<user.email>.endsWith('@company.com') && <user.plan> === 'pro'
|
||||
```
|
||||
|
||||
Read an [environment variable](/workflows/variables#environment-variables) with `{{KEY}}`:
|
||||
|
||||
```javascript
|
||||
{{MAX_RETRIES}} === 3
|
||||
{{FEATURE_ON}} === true
|
||||
{{TIER}} === 'pro'
|
||||
```
|
||||
|
||||
Numbers, booleans, and `null` compare as literals. Every other value is bound as a string, so an apostrophe, quote, or newline inside a secret cannot change what the expression means.
|
||||
|
||||
<Callout type="info">
|
||||
If an expression throws, for example because it reads a field that is not there, the block errors and the run follows the [error path](/workflows/connections) if one is connected. Guard missing values with optional chaining (`?.`) or a null check.
|
||||
</Callout>
|
||||
|
||||
@@ -58,6 +58,25 @@ const apiKey = {{API_KEY}};
|
||||
|
||||
Existing quoted and embedded forms are also supported, including `"{{API_KEY}}"`, `"Bearer {{API_KEY}}"`, and placeholders in template literals. Function and Custom Tool code use the same compiler at the execution boundary. It binds the secret separately from the source instead of pasting plaintext into your code, so quotes, backslashes, newlines, and string values such as `"123"` and `"true"` retain their exact contents and do not become JavaScript or Python literals of another type.
|
||||
|
||||
### Placeholders are always strings
|
||||
|
||||
A placeholder is bound as a value, never parsed as source. That is what keeps a secret from running as code — but it also means `{{KEY}}` always evaluates to a **string**, whatever the value looks like. Convert it when your code needs another type:
|
||||
|
||||
```javascript
|
||||
const retries = Number({{MAX_RETRIES}});
|
||||
const enabled = {{FEATURE_ON}} === 'true';
|
||||
const patterns = JSON.parse({{PATTERN_LIST}});
|
||||
```
|
||||
|
||||
In Python, use `int()`, `== "true"`, and `json.loads()` the same way.
|
||||
|
||||
Two cases are easy to miss:
|
||||
|
||||
- **Booleans.** A bare `if ({{FEATURE_ON}})` is always true, because the string `"false"` is truthy. Compare against `'true'` instead.
|
||||
- **Lists and objects.** Store the value as JSON so you can parse it back. `["^[A-Z]{2}-\\d{4}$", "^\\d{7,15}$"]` becomes an array; a JavaScript array of regex literals does not. It arrives as one long string, and iterating it walks character by character.
|
||||
|
||||
[Condition](/workflows/blocks/condition) blocks differ in one way: a number, boolean, or `null` value compares as a literal there, so `{{MAX_RETRIES}} === 3` is true. Every other value is a string, exactly as it is here.
|
||||
|
||||
JavaScript regex literals can contain a placeholder:
|
||||
|
||||
```javascript
|
||||
|
||||
@@ -85,6 +85,8 @@ Reference them with double curly braces in any block field, including Agent syst
|
||||
Environment variable names must start with a letter or underscore and contain only letters, numbers, and underscores, like `MY_API_KEY`.
|
||||
</Callout>
|
||||
|
||||
Inside Function and Custom Tool code, `{{KEY}}` always evaluates to a **string** — the value is bound, never parsed as source. Use `Number()`, a `=== 'true'` comparison, or `JSON.parse()` when you need another type. See [secret placeholders in code](/workflows/blocks/function#placeholders-are-always-strings).
|
||||
|
||||
### Personal vs. workspace
|
||||
|
||||
| Scope | Visible to | Use for |
|
||||
|
||||
@@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum
|
||||
| `404` | Resource not found | Verify the ID exists and belongs to your workspace |
|
||||
| `429` | Rate limit exceeded | Wait for the duration in the `Retry-After` header |
|
||||
|
||||
### Unrecognized fields are rejected
|
||||
|
||||
Every v2 endpoint validates the request against its published schema — path parameters, query string, and body — and answers `400` for any field it does not declare. A misspelled parameter is an error rather than a silent no-op, so `?limt=20` fails instead of quietly returning an unbounded list.
|
||||
|
||||
This holds for endpoints that declare no query parameters at all. Do not append tracking tags, cache busters, or other extra parameters to a v2 URL; send only what the endpoint documents.
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "BAD_REQUEST",
|
||||
"message": "Invalid request",
|
||||
"details": [
|
||||
{ "code": "unrecognized_keys", "keys": ["limt"], "path": [], "message": "Unrecognized key: \"limt\"" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
Use [Get Billing Status](/api-reference/billing/getBillingStatus) to inspect current credit and storage usage.
|
||||
</Callout>
|
||||
|
||||
@@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum
|
||||
| `404` | Resource not found | Verify the ID exists and belongs to your workspace |
|
||||
| `429` | Rate limit exceeded | Wait for the duration in the `Retry-After` header |
|
||||
|
||||
### Unrecognized fields are rejected
|
||||
|
||||
Every v2 endpoint validates the request against its published schema — path parameters, query string, and body — and answers `400` for any field it does not declare. A misspelled parameter is an error rather than a silent no-op, so `?limt=20` fails instead of quietly returning an unbounded list.
|
||||
|
||||
This holds for endpoints that declare no query parameters at all. Do not append tracking tags, cache busters, or other extra parameters to a v2 URL; send only what the endpoint documents.
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "BAD_REQUEST",
|
||||
"message": "Invalid request",
|
||||
"details": [
|
||||
{ "code": "unrecognized_keys", "keys": ["limt"], "path": [], "message": "Unrecognized key: \"limt\"" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
Use [Get Billing Status](/api-reference/billing/getBillingStatus) to inspect current credit and storage usage.
|
||||
</Callout>
|
||||
|
||||
@@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum
|
||||
| `404` | Resource not found | Verify the ID exists and belongs to your workspace |
|
||||
| `429` | Rate limit exceeded | Wait for the duration in the `Retry-After` header |
|
||||
|
||||
### Unrecognized fields are rejected
|
||||
|
||||
Every v2 endpoint validates the request against its published schema — path parameters, query string, and body — and answers `400` for any field it does not declare. A misspelled parameter is an error rather than a silent no-op, so `?limt=20` fails instead of quietly returning an unbounded list.
|
||||
|
||||
This holds for endpoints that declare no query parameters at all. Do not append tracking tags, cache busters, or other extra parameters to a v2 URL; send only what the endpoint documents.
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "BAD_REQUEST",
|
||||
"message": "Invalid request",
|
||||
"details": [
|
||||
{ "code": "unrecognized_keys", "keys": ["limt"], "path": [], "message": "Unrecognized key: \"limt\"" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
Use [Get Billing Status](/api-reference/billing/getBillingStatus) to inspect current credit and storage usage.
|
||||
</Callout>
|
||||
|
||||
@@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum
|
||||
| `404` | Resource not found | Verify the ID exists and belongs to your workspace |
|
||||
| `429` | Rate limit exceeded | Wait for the duration in the `Retry-After` header |
|
||||
|
||||
### Unrecognized fields are rejected
|
||||
|
||||
Every v2 endpoint validates the request against its published schema — path parameters, query string, and body — and answers `400` for any field it does not declare. A misspelled parameter is an error rather than a silent no-op, so `?limt=20` fails instead of quietly returning an unbounded list.
|
||||
|
||||
This holds for endpoints that declare no query parameters at all. Do not append tracking tags, cache busters, or other extra parameters to a v2 URL; send only what the endpoint documents.
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "BAD_REQUEST",
|
||||
"message": "Invalid request",
|
||||
"details": [
|
||||
{ "code": "unrecognized_keys", "keys": ["limt"], "path": [], "message": "Unrecognized key: \"limt\"" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
Use [Get Billing Status](/api-reference/billing/getBillingStatus) to inspect current credit and storage usage.
|
||||
</Callout>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,7 +36,7 @@
|
||||
"get": {
|
||||
"operationId": "getBillingStatus",
|
||||
"summary": "Get Billing Status",
|
||||
"description": "Return the current plan, billing standing, credit allowance, and storage quota. `credits` and `storage` report the payer's pooled allowances and are null unless the caller can manage that payer's billing; they are always null for a workspace API key. Billing history lives at `GET /api/v2/billing/logs`. Without a Stripe subscription — notably on the free plan — there is no real billing period: `period` is the open interval 1970-01-01 to 9999-12-31 and `credits.used` is lifetime consumption, not consumption since a period start.",
|
||||
"description": "Return the current plan, billing standing, credit allowance, and storage quota. `credits` and `storage` report the payer's pooled allowances and are null unless the caller can manage that payer's billing; they are always null for a workspace API key. Billing history lives at `GET /api/v2/billing/logs`.",
|
||||
"tags": ["Billing"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -47,7 +47,8 @@
|
||||
"schema": {
|
||||
"description": "Workspace whose payer should be resolved. Workspace API keys are pinned to their own workspace.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -101,7 +102,7 @@
|
||||
"get": {
|
||||
"operationId": "listBillingLogs",
|
||||
"summary": "List Billing Logs",
|
||||
"description": "List the credit-denominated billing ledger with source filtering and opaque cursor pagination. `period` defaults to `30d`, so an unqualified request covers only the last 30 days: paginating to `nextCursor: null` exhausts that window, not the whole ledger. Pass `period=all` for full history, or `period=custom` with `startDate` and `endDate` for a specific range.",
|
||||
"description": "List the credit-denominated billing ledger with source filtering and opaque cursor pagination. `period` defaults to `30d`, so an unqualified request covers only the last 30 days: paginating to `nextCursor: null` exhausts that window, not the whole ledger. An inverted custom window is a 400 rather than an empty page.",
|
||||
"tags": ["Billing"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -133,17 +134,18 @@
|
||||
"schema": {
|
||||
"description": "Restrict results to one workspace whose payer the caller can inspect.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "period",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Relative window, all history, or a custom date range.",
|
||||
"description": "Relative window, all history, or a custom date range. `startDate` and `endDate` are accepted only with `custom`; every other value computes its own window.",
|
||||
"schema": {
|
||||
"default": "30d",
|
||||
"description": "Relative window, all history, or a custom date range.",
|
||||
"description": "Relative window, all history, or a custom date range. `startDate` and `endDate` are accepted only with `custom`; every other value computes its own window.",
|
||||
"type": "string",
|
||||
"enum": ["1d", "7d", "30d", "all", "custom"]
|
||||
}
|
||||
@@ -152,22 +154,24 @@
|
||||
"name": "startDate",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Start of a custom window as a Date-parseable string.",
|
||||
"description": "Only include usage events recorded at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.",
|
||||
"schema": {
|
||||
"description": "Start of a custom window as a Date-parseable string.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
|
||||
"description": "Only include usage events recorded at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "endDate",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "End of a custom window as a Date-parseable string; defaults to now.",
|
||||
"description": "Only include usage events recorded at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`, and defaults to now when omitted. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.",
|
||||
"schema": {
|
||||
"description": "End of a custom window as a Date-parseable string; defaults to now.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
|
||||
"description": "Only include usage events recorded at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`, and defaults to now when omitted. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant."
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -187,9 +191,9 @@
|
||||
"name": "cursor",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Opaque cursor returned by the previous page.",
|
||||
"description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.",
|
||||
"schema": {
|
||||
"description": "Opaque cursor returned by the previous page.",
|
||||
"description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
@@ -248,7 +252,7 @@
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-API-Key",
|
||||
"description": "Your Sim API key, personal or workspace-scoped. Generate one from the Sim dashboard under Settings > API Keys. A workspace API key is not accepted everywhere: operations that act on behalf of a specific human — administrative reads, secret access, and irreversible or governance-affecting writes — always reject it, whatever role the key carries. Each such operation says so in its own description, and the rejection surfaces as `403` unless the operation conceals unauthorized resources, in which case it is reported as `404`. Use a personal API key for those."
|
||||
"description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description."
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
@@ -283,13 +287,13 @@
|
||||
}
|
||||
},
|
||||
"Retry-After": {
|
||||
"description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.",
|
||||
"description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991,
|
||||
"title": "Retry after",
|
||||
"description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset."
|
||||
"description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset."
|
||||
}
|
||||
},
|
||||
"X-Run-Id": {
|
||||
@@ -304,7 +308,7 @@
|
||||
},
|
||||
"responses": {
|
||||
"BadRequest": {
|
||||
"description": "The request is invalid.",
|
||||
"description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -334,7 +338,7 @@
|
||||
}
|
||||
},
|
||||
"Forbidden": {
|
||||
"description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.",
|
||||
"description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -364,7 +368,7 @@
|
||||
}
|
||||
},
|
||||
"RunIdConflict": {
|
||||
"description": "The run cannot be started. Two causes share this status, distinguished by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already associated with a different request, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has already reached the maximum workflow-to-workflow call depth.",
|
||||
"description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.",
|
||||
"headers": {
|
||||
"X-Run-Id": {
|
||||
"$ref": "#/components/headers/X-Run-Id"
|
||||
@@ -378,18 +382,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Gone": {
|
||||
"description": "The requested generated resource has expired.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/V2Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PayloadTooLarge": {
|
||||
"description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.",
|
||||
"description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -434,7 +428,7 @@
|
||||
}
|
||||
},
|
||||
"ClientClosedRequest": {
|
||||
"description": "The client closed the connection before the response was produced.",
|
||||
"description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -454,7 +448,7 @@
|
||||
}
|
||||
},
|
||||
"ServiceUnavailable": {
|
||||
"description": "A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.",
|
||||
"description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"$ref": "#/components/headers/Retry-After"
|
||||
@@ -485,7 +479,7 @@
|
||||
"description": "Human-readable explanation of the error."
|
||||
},
|
||||
"details": {
|
||||
"description": "Optional structured error details."
|
||||
"description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address."
|
||||
}
|
||||
},
|
||||
"required": ["code", "message"],
|
||||
@@ -754,7 +748,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself."
|
||||
"description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself."
|
||||
}
|
||||
},
|
||||
"required": ["data", "nextCursor"],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "Sim API v2 — Files & Audit Logs",
|
||||
"description": "Version 2 of the Sim REST API for workspace files and organization audit logs. Lists use opaque cursors, and rate-limit state is returned in response headers. Download File streams raw bytes as `application/octet-stream`; every other response uses the canonical v2 data, cursor-list, or error envelope.",
|
||||
"description": "Version 2 of the Sim REST API for workspace files, resumable uploads, public shares, and organization audit logs.",
|
||||
"version": "2.0.0",
|
||||
"contact": {
|
||||
"name": "Sim Support",
|
||||
@@ -40,7 +40,7 @@
|
||||
"get": {
|
||||
"operationId": "listFiles",
|
||||
"summary": "List Files",
|
||||
"description": "List workspace files with search, sorting, folder filtering, and opaque cursor pagination. Defaults to active files; pass `scope=archived` to page over soft-deleted files, whose `deletedAt` is non-null and which `POST /files/{fileId}/restore` can bring back.",
|
||||
"description": "List workspace files with search, sorting, folder filtering, and opaque cursor pagination. Defaults to active files; pass `scope=archived` to page over soft-deleted ones. A workspace folder tree over 10,000 folders is a `413`.",
|
||||
"tags": ["Files"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -51,6 +51,7 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace whose files should be listed."
|
||||
}
|
||||
},
|
||||
@@ -58,20 +59,20 @@
|
||||
"name": "folderPath",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Restrict results to files directly inside this folder.",
|
||||
"description": "Restrict results to files directly inside this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.",
|
||||
"schema": {
|
||||
"description": "Restrict results to files directly inside this folder.",
|
||||
"type": "string"
|
||||
"description": "Restrict results to files directly inside this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.",
|
||||
"$ref": "#/components/schemas/FolderPathInput"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "scope",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted and that `POST /files/{fileId}/restore` can bring back. `folderPath` resolves against active folders only, so combining it with `scope=archived` returns 404 when the containing folder was archived too.",
|
||||
"description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.",
|
||||
"schema": {
|
||||
"default": "active",
|
||||
"description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted and that `POST /files/{fileId}/restore` can bring back. `folderPath` resolves against active folders only, so combining it with `scope=archived` returns 404 when the containing folder was archived too.",
|
||||
"description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.",
|
||||
"type": "string",
|
||||
"enum": ["active", "archived"]
|
||||
}
|
||||
@@ -92,10 +93,10 @@
|
||||
"name": "sortBy",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Field used to sort the result.",
|
||||
"description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.",
|
||||
"schema": {
|
||||
"default": "uploadedAt",
|
||||
"description": "Field used to sort the result.",
|
||||
"description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.",
|
||||
"type": "string",
|
||||
"enum": ["name", "size", "uploadedAt", "updatedAt"]
|
||||
}
|
||||
@@ -120,8 +121,6 @@
|
||||
"schema": {
|
||||
"description": "Maximum files per page. Values outside 1–1000 are truncated and clamped into that range rather than rejected. Defaults to 100.",
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 1000,
|
||||
"default": 100
|
||||
}
|
||||
},
|
||||
@@ -129,9 +128,9 @@
|
||||
"name": "cursor",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Opaque cursor returned by the previous page.",
|
||||
"description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.",
|
||||
"schema": {
|
||||
"description": "Opaque cursor returned by the previous page.",
|
||||
"description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
@@ -171,6 +170,9 @@
|
||||
"404": {
|
||||
"$ref": "#/components/responses/NotFound"
|
||||
},
|
||||
"413": {
|
||||
"$ref": "#/components/responses/PayloadTooLarge"
|
||||
},
|
||||
"429": {
|
||||
"$ref": "#/components/responses/RateLimited"
|
||||
},
|
||||
@@ -301,6 +303,9 @@
|
||||
"404": {
|
||||
"$ref": "#/components/responses/NotFound"
|
||||
},
|
||||
"413": {
|
||||
"$ref": "#/components/responses/PayloadTooLarge"
|
||||
},
|
||||
"429": {
|
||||
"$ref": "#/components/responses/RateLimited"
|
||||
},
|
||||
@@ -339,6 +344,7 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace that owns the upload session."
|
||||
}
|
||||
},
|
||||
@@ -429,6 +435,7 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace that owns the upload session."
|
||||
}
|
||||
},
|
||||
@@ -492,6 +499,9 @@
|
||||
"409": {
|
||||
"$ref": "#/components/responses/Conflict"
|
||||
},
|
||||
"413": {
|
||||
"$ref": "#/components/responses/PayloadTooLarge"
|
||||
},
|
||||
"429": {
|
||||
"$ref": "#/components/responses/RateLimited"
|
||||
},
|
||||
@@ -530,6 +540,7 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace that owns the upload session."
|
||||
}
|
||||
},
|
||||
@@ -598,7 +609,7 @@
|
||||
"get": {
|
||||
"operationId": "downloadFile",
|
||||
"summary": "Download File",
|
||||
"description": "Download the current file bytes from a workspace. A generated document is served as its compiled artifact, so it returns `409` while that artifact is still compiling and `413` if it renders past the size ceiling.",
|
||||
"description": "Download the current file bytes from a workspace. A generated document is served as its compiled artifact, so it answers `409` while that artifact is still compiling and `413` if it renders past the size ceiling. Downloading records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return. In particular a `HEAD` does not report `Content-Length`, so it cannot be used to size a download in advance; read the size from the file resource instead.",
|
||||
"tags": ["Files"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -622,6 +633,7 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace that owns the file."
|
||||
}
|
||||
}
|
||||
@@ -690,7 +702,7 @@
|
||||
"delete": {
|
||||
"operationId": "deleteFile",
|
||||
"summary": "Delete File",
|
||||
"description": "Archive a workspace file. This is a soft delete: the row is retained with a deletion timestamp, the file stops appearing in the default listing and is no longer readable through the API, and its stored bytes are never removed. List archived files with `GET /files?scope=archived` and reverse the delete with `POST /files/{fileId}/restore`.",
|
||||
"description": "Archive a workspace file. This is a soft delete: the file stops appearing in the default listing and is no longer readable through the API, but its stored bytes are never removed. Archiving an already-archived file is a `404`, not a no-op. List archived files with `GET /files?scope=archived`, and reverse the delete with `POST /files/{fileId}/restore`.",
|
||||
"tags": ["Files"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -714,6 +726,7 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace that owns the file."
|
||||
}
|
||||
}
|
||||
@@ -752,9 +765,6 @@
|
||||
"404": {
|
||||
"$ref": "#/components/responses/NotFound"
|
||||
},
|
||||
"409": {
|
||||
"$ref": "#/components/responses/Conflict"
|
||||
},
|
||||
"429": {
|
||||
"$ref": "#/components/responses/RateLimited"
|
||||
},
|
||||
@@ -834,6 +844,9 @@
|
||||
"409": {
|
||||
"$ref": "#/components/responses/Conflict"
|
||||
},
|
||||
"413": {
|
||||
"$ref": "#/components/responses/PayloadTooLarge"
|
||||
},
|
||||
"429": {
|
||||
"$ref": "#/components/responses/RateLimited"
|
||||
},
|
||||
@@ -850,7 +863,7 @@
|
||||
"post": {
|
||||
"operationId": "restoreFile",
|
||||
"summary": "Restore File",
|
||||
"description": "Reverse a soft delete and return the file to the workspace. Restore is not a pure undo: the file comes back at the workspace root regardless of the folder it was deleted from, and it gains a `_restored` suffix when another file at the root already holds its name — so read `folderPath` and `name` off the response rather than assuming the pre-delete values. Restoring a file that is already active is a no-op that returns that file, so a retry is safe. Returns 400 when the workspace itself has been archived, and 409 when no free restore name could be found.",
|
||||
"description": "Reverse a soft delete and return the file to the workspace. Not a pure undo: the file comes back at the workspace root, and gains a `_restored` suffix when another file there already holds its name, so read `folderPath` and `name` off the response. Restoring an already-active file returns it unchanged, so a retry is safe. An archived workspace is a `400`, and a name the restore could not free is a `409`.",
|
||||
"tags": ["Files"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -915,6 +928,9 @@
|
||||
"409": {
|
||||
"$ref": "#/components/responses/Conflict"
|
||||
},
|
||||
"413": {
|
||||
"$ref": "#/components/responses/PayloadTooLarge"
|
||||
},
|
||||
"429": {
|
||||
"$ref": "#/components/responses/RateLimited"
|
||||
},
|
||||
@@ -955,8 +971,21 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace that owns the file."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "scope",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a `DELETE` soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before `POST /files/{fileId}/restore`. Authorization is identical for both.",
|
||||
"schema": {
|
||||
"default": "active",
|
||||
"description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a `DELETE` soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before `POST /files/{fileId}/restore`. Authorization is identical for both.",
|
||||
"type": "string",
|
||||
"enum": ["active", "archived"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -1009,7 +1038,7 @@
|
||||
"get": {
|
||||
"operationId": "listAuditLogs",
|
||||
"summary": "List Audit Logs",
|
||||
"description": "List an organization audit trail with filters and opaque cursor pagination. Requires an Enterprise subscription and organization admin or owner access. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.",
|
||||
"description": "List an organization audit trail with filters and opaque cursor pagination. Requires an Enterprise subscription and organization admin or owner access. A workspace API key is rejected with `403`; use a personal API key.",
|
||||
"tags": ["Audit Logs"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1026,9 +1055,9 @@
|
||||
"name": "resourceType",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Filter by exact resource type.",
|
||||
"description": "Filter by resource type. Accepts a comma-separated set; members are trimmed and deduplicated, and member order affects neither the result nor the cursor.",
|
||||
"schema": {
|
||||
"description": "Filter by exact resource type.",
|
||||
"description": "Filter by resource type. Accepts a comma-separated set; members are trimmed and deduplicated, and member order affects neither the result nor the cursor.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
@@ -1049,27 +1078,33 @@
|
||||
"description": "Filter to actions in one workspace.",
|
||||
"schema": {
|
||||
"description": "Filter to actions in one workspace.",
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "startDate",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Inclusive ISO 8601 start timestamp.",
|
||||
"description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.",
|
||||
"schema": {
|
||||
"description": "Inclusive ISO 8601 start timestamp.",
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
|
||||
"description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "endDate",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Inclusive ISO 8601 end timestamp.",
|
||||
"description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.",
|
||||
"schema": {
|
||||
"description": "Inclusive ISO 8601 end timestamp.",
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
|
||||
"description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant."
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1099,9 +1134,9 @@
|
||||
"name": "cursor",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Opaque cursor returned by the previous page.",
|
||||
"description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.",
|
||||
"schema": {
|
||||
"description": "Opaque cursor returned by the previous page.",
|
||||
"description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
@@ -1161,9 +1196,6 @@
|
||||
"403": {
|
||||
"$ref": "#/components/responses/Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/NotFound"
|
||||
},
|
||||
"429": {
|
||||
"$ref": "#/components/responses/RateLimited"
|
||||
},
|
||||
@@ -1180,7 +1212,7 @@
|
||||
"get": {
|
||||
"operationId": "getAuditLog",
|
||||
"summary": "Get Audit Log",
|
||||
"description": "Return one organization audit-log entry. Requires an Enterprise subscription and organization admin or owner access. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.",
|
||||
"description": "Return one organization audit-log entry. Requires an Enterprise subscription and organization admin or owner access. A workspace API key is rejected with `403`; use a personal API key.",
|
||||
"tags": ["Audit Logs"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1306,6 +1338,9 @@
|
||||
"409": {
|
||||
"$ref": "#/components/responses/Conflict"
|
||||
},
|
||||
"413": {
|
||||
"$ref": "#/components/responses/PayloadTooLarge"
|
||||
},
|
||||
"429": {
|
||||
"$ref": "#/components/responses/RateLimited"
|
||||
},
|
||||
@@ -1346,6 +1381,7 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace that owns the file."
|
||||
}
|
||||
}
|
||||
@@ -1398,7 +1434,7 @@
|
||||
"patch": {
|
||||
"operationId": "upsertFileShare",
|
||||
"summary": "Enable or Disable File Share",
|
||||
"description": "Create or partially update a server-tokenized public share. Only isActive is required, and an omitted authType keeps the stored auth mode. What happens to password and allowedEmails depends on the resulting mode, because enabling a share always rewrites the credentials the chosen mode does not use: 'public' clears the stored password and empties allowedEmails; 'password' keeps the stored password when password is omitted but empties allowedEmails; 'email' and 'sso' clear the stored password and keep the stored allowedEmails when the field is omitted. Only disabling with isActive false preserves the whole access configuration untouched — it also retains the token, so re-enabling restores the share as it was. Two enabling combinations are rejected outright with a 400 instead of being partially applied: 'password' when neither a password is supplied nor one is already stored, and 'email' or 'sso' when the resulting allowedEmails would be empty because none was supplied and none is stored. On a file that has never been shared there is nothing stored to fall back on, so enabling any mode other than 'public' must carry its credential in the same request. A workspace API key cannot call this operation. Because unauthorized resources are concealed, the rejection is reported as `404` rather than `403`; use a personal API key.",
|
||||
"description": "Create or partially update a server-tokenized public share. Only `isActive` is required; each other field states what enabling a mode does to it. Enabling any mode other than `public` on a file that has never been shared must carry its credential in the same request. A workspace API key is rejected with `403`; use a personal API key.",
|
||||
"tags": ["Files"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1460,6 +1496,9 @@
|
||||
"404": {
|
||||
"$ref": "#/components/responses/NotFound"
|
||||
},
|
||||
"413": {
|
||||
"$ref": "#/components/responses/PayloadTooLarge"
|
||||
},
|
||||
"429": {
|
||||
"$ref": "#/components/responses/RateLimited"
|
||||
},
|
||||
@@ -1607,6 +1646,9 @@
|
||||
"409": {
|
||||
"$ref": "#/components/responses/Conflict"
|
||||
},
|
||||
"413": {
|
||||
"$ref": "#/components/responses/PayloadTooLarge"
|
||||
},
|
||||
"429": {
|
||||
"$ref": "#/components/responses/RateLimited"
|
||||
},
|
||||
@@ -1623,7 +1665,7 @@
|
||||
"get": {
|
||||
"operationId": "listFilesFolders",
|
||||
"summary": "List Folders",
|
||||
"description": "List workspace file folders with optional parent-path filtering and sorting. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch.",
|
||||
"description": "List workspace file folders with optional parent-path filtering and sorting. The bounded set is returned in one page; `nextCursor` is always null.",
|
||||
"tags": ["Files"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1634,6 +1676,7 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace whose folders should be listed."
|
||||
}
|
||||
},
|
||||
@@ -1644,7 +1687,7 @@
|
||||
"description": "Restrict results to direct children of this parent path.",
|
||||
"schema": {
|
||||
"description": "Restrict results to direct children of this parent path.",
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/FolderPathInput"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1663,10 +1706,10 @@
|
||||
"name": "sortBy",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Field used to sort the result.",
|
||||
"description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.",
|
||||
"schema": {
|
||||
"default": "name",
|
||||
"description": "Field used to sort the result.",
|
||||
"description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.",
|
||||
"type": "string",
|
||||
"enum": ["name", "createdAt", "updatedAt"]
|
||||
}
|
||||
@@ -1782,6 +1825,9 @@
|
||||
"409": {
|
||||
"$ref": "#/components/responses/Conflict"
|
||||
},
|
||||
"413": {
|
||||
"$ref": "#/components/responses/PayloadTooLarge"
|
||||
},
|
||||
"429": {
|
||||
"$ref": "#/components/responses/RateLimited"
|
||||
},
|
||||
@@ -1846,6 +1892,9 @@
|
||||
"409": {
|
||||
"$ref": "#/components/responses/Conflict"
|
||||
},
|
||||
"413": {
|
||||
"$ref": "#/components/responses/PayloadTooLarge"
|
||||
},
|
||||
"429": {
|
||||
"$ref": "#/components/responses/RateLimited"
|
||||
},
|
||||
@@ -1871,6 +1920,7 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace containing the folder."
|
||||
}
|
||||
},
|
||||
@@ -1881,16 +1931,30 @@
|
||||
"description": "Path of the folder to delete.",
|
||||
"schema": {
|
||||
"description": "Path of the folder to delete.",
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/NonRootFolderPathInput"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "recursive",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Delete nested files and folders when true.",
|
||||
"description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.",
|
||||
"schema": {
|
||||
"description": "Delete nested files and folders when true.",
|
||||
"description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.",
|
||||
"enum": [
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
"on",
|
||||
"y",
|
||||
"enabled",
|
||||
"false",
|
||||
"0",
|
||||
"no",
|
||||
"off",
|
||||
"n",
|
||||
"disabled"
|
||||
],
|
||||
"default": "false",
|
||||
"type": "string"
|
||||
}
|
||||
@@ -1952,7 +2016,7 @@
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-API-Key",
|
||||
"description": "Your Sim API key, personal or workspace-scoped. Generate one from the Sim dashboard under Settings > API Keys. A workspace API key is not accepted everywhere: operations that act on behalf of a specific human — administrative reads, secret access, and irreversible or governance-affecting writes — always reject it, whatever role the key carries. Each such operation says so in its own description, and the rejection surfaces as `403` unless the operation conceals unauthorized resources, in which case it is reported as `404`. Use a personal API key for those."
|
||||
"description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description."
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
@@ -2012,13 +2076,13 @@
|
||||
}
|
||||
},
|
||||
"Retry-After": {
|
||||
"description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.",
|
||||
"description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991,
|
||||
"title": "Retry after",
|
||||
"description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset."
|
||||
"description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset."
|
||||
}
|
||||
},
|
||||
"X-Run-Id": {
|
||||
@@ -2033,7 +2097,7 @@
|
||||
},
|
||||
"responses": {
|
||||
"BadRequest": {
|
||||
"description": "The request is invalid.",
|
||||
"description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -2063,7 +2127,7 @@
|
||||
}
|
||||
},
|
||||
"Forbidden": {
|
||||
"description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.",
|
||||
"description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -2093,7 +2157,7 @@
|
||||
}
|
||||
},
|
||||
"RunIdConflict": {
|
||||
"description": "The run cannot be started. Two causes share this status, distinguished by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already associated with a different request, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has already reached the maximum workflow-to-workflow call depth.",
|
||||
"description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.",
|
||||
"headers": {
|
||||
"X-Run-Id": {
|
||||
"$ref": "#/components/headers/X-Run-Id"
|
||||
@@ -2107,18 +2171,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Gone": {
|
||||
"description": "The requested generated resource has expired.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/V2Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PayloadTooLarge": {
|
||||
"description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.",
|
||||
"description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -2163,7 +2217,7 @@
|
||||
}
|
||||
},
|
||||
"ClientClosedRequest": {
|
||||
"description": "The client closed the connection before the response was produced.",
|
||||
"description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -2183,7 +2237,7 @@
|
||||
}
|
||||
},
|
||||
"ServiceUnavailable": {
|
||||
"description": "A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.",
|
||||
"description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"$ref": "#/components/headers/Retry-After"
|
||||
@@ -2214,7 +2268,7 @@
|
||||
"description": "Human-readable explanation of the error."
|
||||
},
|
||||
"details": {
|
||||
"description": "Optional structured error details."
|
||||
"description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address."
|
||||
}
|
||||
},
|
||||
"required": ["code", "message"],
|
||||
@@ -2235,6 +2289,12 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"FolderPathInput": {
|
||||
"title": "Folder path input",
|
||||
"description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.",
|
||||
"maxLength": 4096,
|
||||
"type": "string"
|
||||
},
|
||||
"V2File": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2251,12 +2311,12 @@
|
||||
"size": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) the stored file is the generation source rather than the rendered document, so this does not predict how many bytes `GET /files/{fileId}` returns — that endpoint serves the compiled artifact, which is typically much larger.",
|
||||
"description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes `GET /files/{fileId}` returns.",
|
||||
"examples": [1024]
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) the stored file is the generation source, so this describes the source and not what `GET /files/{fileId}` serves — that endpoint returns the compiled artifact under the rendered document type.",
|
||||
"description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type `GET /files/{fileId}` serves.",
|
||||
"examples": ["text/csv"]
|
||||
},
|
||||
"key": {
|
||||
@@ -2266,7 +2326,9 @@
|
||||
},
|
||||
"folderPath": {
|
||||
"type": "string",
|
||||
"description": "Canonical containing-folder path. `/` is the workspace root."
|
||||
"title": "Folder path",
|
||||
"description": "Canonical containing-folder path. `/` is the workspace root.",
|
||||
"maxLength": 4096
|
||||
},
|
||||
"uploadedByEmail": {
|
||||
"type": "string",
|
||||
@@ -2336,7 +2398,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself."
|
||||
"description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself."
|
||||
}
|
||||
},
|
||||
"required": ["data", "nextCursor"],
|
||||
@@ -2398,6 +2460,7 @@
|
||||
"workspaceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace in which to create the file."
|
||||
},
|
||||
"name": {
|
||||
@@ -2414,11 +2477,11 @@
|
||||
},
|
||||
"folderPath": {
|
||||
"description": "Canonical containing-folder path. Omit for the workspace root.",
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/FolderPathInput"
|
||||
},
|
||||
"content": {
|
||||
"default": "",
|
||||
"description": "Initial file content. Omit or send an empty string for a zero-byte file. The 70,000,000-character bound is a JSON-envelope guard, not the file-size limit: the decoded bytes must be at most 50 MiB, so a longer base64 payload is admitted here and then rejected with 413. Use an upload session for anything larger.",
|
||||
"description": "Initial file content. Omit or send an empty string for a zero-byte file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. Use an upload session for anything larger.",
|
||||
"type": "string",
|
||||
"maxLength": 70000000
|
||||
},
|
||||
@@ -2514,7 +2577,7 @@
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "Signed URL to which the file bytes are uploaded."
|
||||
"description": "Signed URL to which the file bytes are uploaded. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. Success is `204` with an empty body. A failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response: `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. The URL is signed and self-describing — construct it from this field only, never by hand."
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
@@ -2612,6 +2675,7 @@
|
||||
"workspaceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace in which the file will be registered."
|
||||
},
|
||||
"name": {
|
||||
@@ -2634,7 +2698,7 @@
|
||||
},
|
||||
"folderPath": {
|
||||
"description": "Canonical destination folder path. Omit for the workspace root.",
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/FolderPathInput"
|
||||
}
|
||||
},
|
||||
"required": ["workspaceId", "name", "contentType", "size"],
|
||||
@@ -2667,7 +2731,7 @@
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "Signed URL for this upload part."
|
||||
"description": "Signed URL for this upload part. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. Success is `204` with an empty body. A failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response: `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. The URL is signed and self-describing — construct it from this field only, never by hand."
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
@@ -2783,6 +2847,7 @@
|
||||
"workspaceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace that owns the file."
|
||||
},
|
||||
"name": {
|
||||
@@ -2838,6 +2903,7 @@
|
||||
"workspaceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace that owns the archived file."
|
||||
}
|
||||
},
|
||||
@@ -2931,12 +2997,12 @@
|
||||
"size": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) the stored file is the generation source rather than the rendered document, so this does not predict how many bytes `GET /files/{fileId}` returns — that endpoint serves the compiled artifact, which is typically much larger.",
|
||||
"description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes `GET /files/{fileId}` returns.",
|
||||
"examples": [1024]
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) the stored file is the generation source, so this describes the source and not what `GET /files/{fileId}` serves — that endpoint returns the compiled artifact under the rendered document type.",
|
||||
"description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type `GET /files/{fileId}` serves.",
|
||||
"examples": ["text/csv"]
|
||||
},
|
||||
"key": {
|
||||
@@ -2946,7 +3012,9 @@
|
||||
},
|
||||
"folderPath": {
|
||||
"type": "string",
|
||||
"description": "Canonical containing-folder path. `/` is the workspace root."
|
||||
"title": "Folder path",
|
||||
"description": "Canonical containing-folder path. `/` is the workspace root.",
|
||||
"maxLength": 4096
|
||||
},
|
||||
"uploadedByEmail": {
|
||||
"type": "string",
|
||||
@@ -3196,7 +3264,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself."
|
||||
"description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself."
|
||||
}
|
||||
},
|
||||
"required": ["data", "nextCursor"],
|
||||
@@ -3311,6 +3379,7 @@
|
||||
"workspaceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace containing the files."
|
||||
},
|
||||
"fileIds": {
|
||||
@@ -3325,7 +3394,7 @@
|
||||
},
|
||||
"targetFolderPath": {
|
||||
"description": "Destination folder path. Omit to move files to the workspace root.",
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/FolderPathInput"
|
||||
}
|
||||
},
|
||||
"required": ["workspaceId", "fileIds"],
|
||||
@@ -3405,25 +3474,26 @@
|
||||
"workspaceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace that owns the file."
|
||||
},
|
||||
"isActive": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the share should resolve."
|
||||
"description": "Whether the share should resolve. Disabling preserves the token and the whole access configuration, so re-enabling restores the share as it was; enabling rewrites the credentials the resulting mode does not use."
|
||||
},
|
||||
"authType": {
|
||||
"description": "How access to the share is gated.",
|
||||
"description": "How access to the share is gated. The stored mode is kept when omitted. Enabling `public` clears the stored password and empties `allowedEmails`; `password` empties `allowedEmails`; `email` and `sso` clear the stored password.",
|
||||
"type": "string",
|
||||
"enum": ["public", "password", "email", "sso"]
|
||||
},
|
||||
"password": {
|
||||
"description": "Password for a password-gated share.",
|
||||
"description": "Password for a password-gated share. Kept when omitted; enabling `password` with neither a supplied nor a stored password is a 400.",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024
|
||||
},
|
||||
"allowedEmails": {
|
||||
"description": "Allowed addresses or @domain patterns for email and SSO shares.",
|
||||
"description": "Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400.",
|
||||
"maxItems": 200,
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -3455,12 +3525,13 @@
|
||||
"workspaceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace that owns the file."
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"maxLength": 70000000,
|
||||
"description": "Complete replacement content for the file. The 70,000,000-character bound is a JSON-envelope guard, not the file-size limit: the decoded bytes must be at most 50 MiB, so a longer base64 payload is admitted here and then rejected with 413."
|
||||
"description": "Complete replacement content for the file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`."
|
||||
},
|
||||
"encoding": {
|
||||
"default": "utf-8",
|
||||
@@ -3531,6 +3602,7 @@
|
||||
"workspaceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace containing the files."
|
||||
},
|
||||
"fileIds": {
|
||||
@@ -3564,11 +3636,15 @@
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Canonical folder path used as the public folder identifier."
|
||||
"title": "Non-root folder path",
|
||||
"description": "Canonical folder path used as the public folder identifier.",
|
||||
"maxLength": 4096
|
||||
},
|
||||
"parentPath": {
|
||||
"type": "string",
|
||||
"description": "Canonical parent path; `/` is the root."
|
||||
"title": "Folder path",
|
||||
"description": "Canonical parent path; `/` is the root.",
|
||||
"maxLength": 4096
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
@@ -3605,7 +3681,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself."
|
||||
"description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change."
|
||||
}
|
||||
},
|
||||
"required": ["data", "nextCursor"],
|
||||
@@ -3626,17 +3702,24 @@
|
||||
"title": "File folder response",
|
||||
"description": "A single workspace file folder."
|
||||
},
|
||||
"NonRootFolderPathInput": {
|
||||
"title": "Non-root folder path input",
|
||||
"description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.",
|
||||
"maxLength": 4096,
|
||||
"type": "string"
|
||||
},
|
||||
"CreateFileFolderRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workspaceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace in which to create the folder."
|
||||
},
|
||||
"path": {
|
||||
"description": "Path of the folder to create.",
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/NonRootFolderPathInput"
|
||||
}
|
||||
},
|
||||
"required": ["workspaceId", "path"],
|
||||
@@ -3650,15 +3733,16 @@
|
||||
"workspaceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace containing the folder."
|
||||
},
|
||||
"path": {
|
||||
"description": "Current folder path.",
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/NonRootFolderPathInput"
|
||||
},
|
||||
"destinationPath": {
|
||||
"description": "New full path for the folder and its descendants.",
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/NonRootFolderPathInput"
|
||||
}
|
||||
},
|
||||
"required": ["workspaceId", "path", "destinationPath"],
|
||||
@@ -3671,7 +3755,9 @@
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Deleted folder path."
|
||||
"title": "Folder path",
|
||||
"description": "Deleted folder path.",
|
||||
"maxLength": 4096
|
||||
},
|
||||
"deleted": {
|
||||
"type": "boolean",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"get": {
|
||||
"operationId": "listKnowledgeBases",
|
||||
"summary": "List Knowledge Bases",
|
||||
"description": "List knowledge bases in a workspace with folder filtering, search, and sorting. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null. An unknown `folderPath` is a 404. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.",
|
||||
"description": "List knowledge bases in a workspace with folder filtering, search, sorting, and opaque cursor pagination. A workspace folder tree over 10,000 folders is a `413`.",
|
||||
"tags": ["Knowledge Bases"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -47,6 +47,7 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace whose knowledge bases should be listed."
|
||||
}
|
||||
},
|
||||
@@ -54,19 +55,19 @@
|
||||
"name": "folderPath",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Restrict results to knowledge bases in this folder.",
|
||||
"description": "Restrict results to knowledge bases in this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.",
|
||||
"schema": {
|
||||
"description": "Restrict results to knowledge bases in this folder.",
|
||||
"type": "string"
|
||||
"description": "Restrict results to knowledge bases in this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.",
|
||||
"$ref": "#/components/schemas/FolderPathInput"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "search",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Case-insensitive substring search on the resource name.",
|
||||
"description": "Case-insensitive substring match against the resource name.",
|
||||
"schema": {
|
||||
"description": "Case-insensitive substring search on the resource name.",
|
||||
"description": "Case-insensitive substring match against the resource name.",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200
|
||||
@@ -76,10 +77,10 @@
|
||||
"name": "sortBy",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Field used to sort the result.",
|
||||
"description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.",
|
||||
"schema": {
|
||||
"default": "createdAt",
|
||||
"description": "Field used to sort the result.",
|
||||
"description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.",
|
||||
"type": "string",
|
||||
"enum": ["name", "createdAt", "updatedAt"]
|
||||
}
|
||||
@@ -113,9 +114,9 @@
|
||||
"name": "cursor",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Opaque cursor returned by the previous page.",
|
||||
"description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.",
|
||||
"schema": {
|
||||
"description": "Opaque cursor returned by the previous page.",
|
||||
"description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
@@ -172,7 +173,7 @@
|
||||
"post": {
|
||||
"operationId": "createKnowledgeBase",
|
||||
"summary": "Create Knowledge Base",
|
||||
"description": "Create a knowledge base in a workspace with optional folder placement and chunking configuration. An unknown `folderPath` is a 404. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.",
|
||||
"description": "Create a knowledge base in a workspace with optional folder placement and chunking configuration. An unknown `folderPath` is a `404`. A workspace folder tree over 10,000 folders is a `413`.",
|
||||
"tags": ["Knowledge Bases"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -241,7 +242,7 @@
|
||||
"get": {
|
||||
"operationId": "getKnowledgeBase",
|
||||
"summary": "Get Knowledge Base",
|
||||
"description": "Retrieve a knowledge base by identifier. Inaccessible knowledge bases are reported as not found. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.",
|
||||
"description": "Retrieve a knowledge base by identifier. Inaccessible knowledge bases are reported as not found. A workspace folder tree over 10,000 folders is a `413`.",
|
||||
"tags": ["Knowledge Bases"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -318,7 +319,7 @@
|
||||
"patch": {
|
||||
"operationId": "updateKnowledgeBase",
|
||||
"summary": "Update Knowledge Base",
|
||||
"description": "Update a knowledge base name, description, chunking configuration, or folder placement. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.",
|
||||
"description": "Update a knowledge base name, description, chunking configuration, or folder placement. A workspace folder tree over 10,000 folders is a `413`.",
|
||||
"tags": ["Knowledge Bases"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -474,7 +475,7 @@
|
||||
"post": {
|
||||
"operationId": "searchKnowledge",
|
||||
"summary": "Search Knowledge",
|
||||
"description": "Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Set `rerankerEnabled` with a `rerankerModel` to re-order the retrieved chunks with a reranking model before truncating to `topK`; reranked results carry a `rerankerScore` and are ordered by it, and reranking is billed as an additional search unit. Every result names the `knowledgeBaseId` it came from. The request body is capped at 2 MiB; a larger body is a 413.",
|
||||
"description": "Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Every result names the `knowledgeBaseId` it came from. A request body over 2 MiB is a `413`.",
|
||||
"tags": ["Knowledge Bases"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -543,7 +544,7 @@
|
||||
"get": {
|
||||
"operationId": "listKnowledgeTags",
|
||||
"summary": "List Tags",
|
||||
"description": "List the knowledge base's tag vocabulary: each tag's display name, the slot it is stored in, and its field type. Display names are what tag filters and the tag values on document reads use; slots are what document writes set. Every slot listed here is writable, in its declared type: `tag1`..`tag7` take a string, `number1`..`number5` a number, `date1`..`date2` a `YYYY-MM-DD` string, and `boolean1`..`boolean3` a boolean. The vocabulary is bounded by the fixed slot table. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch.",
|
||||
"description": "List the knowledge base's tag vocabulary: each tag's display name, the slot it is stored in, and its field type. Filters and document reads use display names; document writes address slots. The bounded set is returned in one page; `nextCursor` is always null.",
|
||||
"tags": ["Knowledge Bases"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -619,7 +620,7 @@
|
||||
"get": {
|
||||
"operationId": "listKnowledgeDocuments",
|
||||
"summary": "List Documents",
|
||||
"description": "List documents in a knowledge base with filename search, state filtering, tag filtering, sorting, and opaque cursor pagination. Each document carries its tag values keyed by tag display name; resolve those names to write slots with `GET /api/v2/knowledge/{id}/tags`.",
|
||||
"description": "List documents in a knowledge base with filename search, state filtering, tag filtering, sorting, and opaque cursor pagination. Tag values are keyed by display name; resolve those to write slots with `GET /api/v2/knowledge/{id}/tags`.",
|
||||
"tags": ["Knowledge Bases"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -648,10 +649,10 @@
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Maximum documents to return, between 1 and 100.",
|
||||
"description": "Maximum documents to return per page. Must be a whole number from 1 to 100. Defaults to 50.",
|
||||
"schema": {
|
||||
"default": 50,
|
||||
"description": "Maximum documents to return, between 1 and 100.",
|
||||
"description": "Maximum documents to return per page. Must be a whole number from 1 to 100. Defaults to 50.",
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100
|
||||
@@ -661,10 +662,12 @@
|
||||
"name": "search",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Case-insensitive filename search.",
|
||||
"description": "Case-insensitive substring match against the document filename.",
|
||||
"schema": {
|
||||
"description": "Case-insensitive filename search.",
|
||||
"type": "string"
|
||||
"description": "Case-insensitive substring match against the document filename.",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -683,10 +686,10 @@
|
||||
"name": "sortBy",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Document field used to sort results.",
|
||||
"description": "Field used to sort the result. Sorting by `filename` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.",
|
||||
"schema": {
|
||||
"default": "uploadedAt",
|
||||
"description": "Document field used to sort results.",
|
||||
"description": "Field used to sort the result. Sorting by `filename` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"filename",
|
||||
@@ -715,9 +718,9 @@
|
||||
"name": "cursor",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Opaque cursor returned by the previous page.",
|
||||
"description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.",
|
||||
"schema": {
|
||||
"description": "Opaque cursor returned by the previous page.",
|
||||
"description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
@@ -726,9 +729,9 @@
|
||||
"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.",
|
||||
"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\"}]`. Every filter must hold, including two that name the same tag. 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.",
|
||||
"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\"}]`. Every filter must hold, including two that name the same tag. A name that is not defined in this knowledge base is rejected, never ignored.",
|
||||
"examples": [
|
||||
"[{\"tagName\":\"category\",\"operator\":\"eq\",\"value\":\"billing\"}]"
|
||||
],
|
||||
@@ -784,7 +787,7 @@
|
||||
"patch": {
|
||||
"operationId": "bulkUpdateKnowledgeDocuments",
|
||||
"summary": "Bulk Enable or Disable Documents",
|
||||
"description": "Enable or disable many documents in one request, either by identifier (up to 100) or, with `selectAll`, every document in the knowledge base optionally narrowed by `enabledFilter`. Disabling keeps a document indexed but excludes it from search. Bulk delete is deliberately not offered: the bulk path records no audit entries, so deletions go through `DELETE /api/v2/knowledge/{id}/documents/{documentId}`, which audits each one. An identifier request echoes the documents it changed in `documentIds`; a `selectAll` request omits that field because the selection is unbounded, and reports `updatedCount` alone. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.",
|
||||
"description": "Enable or disable many documents in one request, either by identifier or, with `selectAll`, every document in the knowledge base. Bulk delete is not offered; delete documents one at a time with `DELETE /api/v2/knowledge/{id}/documents/{documentId}`. A workspace API key is rejected with `403`; use a personal API key.",
|
||||
"tags": ["Knowledge Bases"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -844,6 +847,9 @@
|
||||
"404": {
|
||||
"$ref": "#/components/responses/NotFound"
|
||||
},
|
||||
"413": {
|
||||
"$ref": "#/components/responses/PayloadTooLarge"
|
||||
},
|
||||
"429": {
|
||||
"$ref": "#/components/responses/RateLimited"
|
||||
},
|
||||
@@ -880,6 +886,7 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace that owns the knowledge base."
|
||||
}
|
||||
}
|
||||
@@ -1072,6 +1079,7 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace that owns the knowledge base."
|
||||
}
|
||||
},
|
||||
@@ -1173,6 +1181,7 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace that owns the knowledge base."
|
||||
}
|
||||
},
|
||||
@@ -1236,6 +1245,9 @@
|
||||
"409": {
|
||||
"$ref": "#/components/responses/Conflict"
|
||||
},
|
||||
"413": {
|
||||
"$ref": "#/components/responses/PayloadTooLarge"
|
||||
},
|
||||
"429": {
|
||||
"$ref": "#/components/responses/RateLimited"
|
||||
},
|
||||
@@ -1285,6 +1297,7 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace that owns the knowledge base."
|
||||
}
|
||||
},
|
||||
@@ -1441,7 +1454,7 @@
|
||||
"patch": {
|
||||
"operationId": "updateKnowledgeDocument",
|
||||
"summary": "Update Document",
|
||||
"description": "Rename a document, enable or disable it for search, set any of its 17 tag slots, or requeue it for processing. A tag slot takes its declared type — a string for `tag1`..`tag7`, a number for `number1`..`number5`, a `YYYY-MM-DD` string for `date1`..`date2`, a boolean for `boolean1`..`boolean3` — and a value that is not valid for the slot is a `400` rather than a silently cleared tag. Resolve a display name to its slot with `GET /api/v2/knowledge/{id}/tags`. Absent fields are unchanged. Only caller-owned fields are accepted: derived indexing state (`chunkCount`, `tokenCount`, `characterCount`, `processingStatus`, `processingError`) is written by the processing pipeline and cannot be asserted here. `retryProcessing: true` re-queues a failed or stuck document and must be sent on its own — it runs instead of, not alongside, the field updates — and it answers with a queue acknowledgement rather than the document. Otherwise the updated document is returned; it omits the connector provenance the detail read carries, so re-read with GET when that is needed. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.",
|
||||
"description": "Rename a document, enable or disable it for search, set any of its 17 tag slots, or requeue it for processing. Absent fields are unchanged, and derived indexing state is read-only. Resolve a tag display name to its slot with `GET /api/v2/knowledge/{id}/tags`. The returned document omits the connector provenance the detail read carries. A workspace API key is rejected with `403`; use a personal API key.",
|
||||
"tags": ["Knowledge Bases"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1512,6 +1525,9 @@
|
||||
"404": {
|
||||
"$ref": "#/components/responses/NotFound"
|
||||
},
|
||||
"413": {
|
||||
"$ref": "#/components/responses/PayloadTooLarge"
|
||||
},
|
||||
"429": {
|
||||
"$ref": "#/components/responses/RateLimited"
|
||||
},
|
||||
@@ -1526,7 +1542,7 @@
|
||||
"delete": {
|
||||
"operationId": "deleteKnowledgeDocument",
|
||||
"summary": "Delete Document",
|
||||
"description": "Remove one document from a knowledge base. What that means depends on the document. A directly uploaded document is deleted outright along with its indexed chunks. A connector-backed document is instead excluded: its row survives, marked excluded and disabled so it stops being searchable and a later connector sync does not re-add it, and its embeddings are not deleted. Either way the document no longer appears in listings or search results.",
|
||||
"description": "Remove one document from a knowledge base. An uploaded document is deleted outright with its indexed chunks. A connector-backed document is instead excluded — its row and embeddings survive, but it stops being searchable and a later sync does not re-add it. Either way it no longer appears in listings or search results.",
|
||||
"tags": ["Knowledge Bases"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1613,7 +1629,7 @@
|
||||
"get": {
|
||||
"operationId": "listKnowledgeFolders",
|
||||
"summary": "List Folders",
|
||||
"description": "List folders in the knowledge-base folder tree with filtering and sorting. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.",
|
||||
"description": "List folders in the knowledge-base folder tree with filtering and sorting. The bounded set is returned in one page; `nextCursor` is always null. A workspace folder tree over 10,000 folders is a `413`.",
|
||||
"tags": ["Knowledge Bases"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1624,6 +1640,7 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace whose folders should be listed."
|
||||
}
|
||||
},
|
||||
@@ -1634,7 +1651,7 @@
|
||||
"description": "Restrict results to direct children of this parent path.",
|
||||
"schema": {
|
||||
"description": "Restrict results to direct children of this parent path.",
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/FolderPathInput"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1653,10 +1670,10 @@
|
||||
"name": "sortBy",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Field used to sort the result.",
|
||||
"description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.",
|
||||
"schema": {
|
||||
"default": "name",
|
||||
"description": "Field used to sort the result.",
|
||||
"description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.",
|
||||
"type": "string",
|
||||
"enum": ["name", "createdAt", "updatedAt"]
|
||||
}
|
||||
@@ -1725,7 +1742,7 @@
|
||||
"post": {
|
||||
"operationId": "createKnowledgeFolder",
|
||||
"summary": "Create Folder",
|
||||
"description": "Create a folder in the knowledge-base folder tree. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.",
|
||||
"description": "Create a folder in the knowledge-base folder tree. A workspace folder tree over 10,000 folders is a `413`.",
|
||||
"tags": ["Knowledge Bases"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -1792,7 +1809,7 @@
|
||||
"patch": {
|
||||
"operationId": "relocateKnowledgeFolder",
|
||||
"summary": "Rename or Move Folder",
|
||||
"description": "Rename or move a folder and atomically rewrite descendant paths. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.",
|
||||
"description": "Rename or move a folder and atomically rewrite descendant paths. A workspace folder tree over 10,000 folders is a `413`.",
|
||||
"tags": ["Knowledge Bases"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -1870,6 +1887,7 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace containing the folder."
|
||||
}
|
||||
},
|
||||
@@ -1880,16 +1898,30 @@
|
||||
"description": "Path of the folder to delete.",
|
||||
"schema": {
|
||||
"description": "Path of the folder to delete.",
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/NonRootFolderPathInput"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "recursive",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Delete nested files and folders when true.",
|
||||
"description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.",
|
||||
"schema": {
|
||||
"description": "Delete nested files and folders when true.",
|
||||
"description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.",
|
||||
"enum": [
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
"on",
|
||||
"y",
|
||||
"enabled",
|
||||
"false",
|
||||
"0",
|
||||
"no",
|
||||
"off",
|
||||
"n",
|
||||
"disabled"
|
||||
],
|
||||
"default": "false",
|
||||
"type": "string"
|
||||
}
|
||||
@@ -1954,7 +1986,7 @@
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-API-Key",
|
||||
"description": "Your Sim API key, personal or workspace-scoped. Generate one from the Sim dashboard under Settings > API Keys. A workspace API key is not accepted everywhere: operations that act on behalf of a specific human — administrative reads, secret access, and irreversible or governance-affecting writes — always reject it, whatever role the key carries. Each such operation says so in its own description, and the rejection surfaces as `403` unless the operation conceals unauthorized resources, in which case it is reported as `404`. Use a personal API key for those."
|
||||
"description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description."
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
@@ -1989,13 +2021,13 @@
|
||||
}
|
||||
},
|
||||
"Retry-After": {
|
||||
"description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.",
|
||||
"description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991,
|
||||
"title": "Retry after",
|
||||
"description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset."
|
||||
"description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset."
|
||||
}
|
||||
},
|
||||
"X-Run-Id": {
|
||||
@@ -2010,7 +2042,7 @@
|
||||
},
|
||||
"responses": {
|
||||
"BadRequest": {
|
||||
"description": "The request is invalid.",
|
||||
"description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -2040,7 +2072,7 @@
|
||||
}
|
||||
},
|
||||
"Forbidden": {
|
||||
"description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.",
|
||||
"description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -2070,7 +2102,7 @@
|
||||
}
|
||||
},
|
||||
"RunIdConflict": {
|
||||
"description": "The run cannot be started. Two causes share this status, distinguished by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already associated with a different request, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has already reached the maximum workflow-to-workflow call depth.",
|
||||
"description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.",
|
||||
"headers": {
|
||||
"X-Run-Id": {
|
||||
"$ref": "#/components/headers/X-Run-Id"
|
||||
@@ -2084,18 +2116,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Gone": {
|
||||
"description": "The requested generated resource has expired.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/V2Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PayloadTooLarge": {
|
||||
"description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.",
|
||||
"description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -2140,7 +2162,7 @@
|
||||
}
|
||||
},
|
||||
"ClientClosedRequest": {
|
||||
"description": "The client closed the connection before the response was produced.",
|
||||
"description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -2160,7 +2182,7 @@
|
||||
}
|
||||
},
|
||||
"ServiceUnavailable": {
|
||||
"description": "A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.",
|
||||
"description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"$ref": "#/components/headers/Retry-After"
|
||||
@@ -2191,7 +2213,7 @@
|
||||
"description": "Human-readable explanation of the error."
|
||||
},
|
||||
"details": {
|
||||
"description": "Optional structured error details."
|
||||
"description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address."
|
||||
}
|
||||
},
|
||||
"required": ["code", "message"],
|
||||
@@ -2212,6 +2234,12 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"FolderPathInput": {
|
||||
"title": "Folder path input",
|
||||
"description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.",
|
||||
"maxLength": 4096,
|
||||
"type": "string"
|
||||
},
|
||||
"V2KnowledgeBase": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2289,7 +2317,9 @@
|
||||
},
|
||||
"folderPath": {
|
||||
"type": "string",
|
||||
"title": "Folder path",
|
||||
"description": "Canonical containing-folder path; `/` is the workspace root.",
|
||||
"maxLength": 4096,
|
||||
"examples": ["/Product"]
|
||||
}
|
||||
},
|
||||
@@ -2388,7 +2418,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself."
|
||||
"description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself."
|
||||
}
|
||||
},
|
||||
"required": ["data", "nextCursor"],
|
||||
@@ -2446,6 +2476,7 @@
|
||||
"workspaceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace in which to create the knowledge base."
|
||||
},
|
||||
"name": {
|
||||
@@ -2472,7 +2503,7 @@
|
||||
},
|
||||
"folderPath": {
|
||||
"description": "Containing folder path; omission creates the knowledge base at the root.",
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/FolderPathInput"
|
||||
}
|
||||
},
|
||||
"required": ["workspaceId", "name"],
|
||||
@@ -2486,6 +2517,7 @@
|
||||
"workspaceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace that owns the knowledge base."
|
||||
},
|
||||
"name": {
|
||||
@@ -2507,7 +2539,7 @@
|
||||
},
|
||||
"folderPath": {
|
||||
"description": "New containing-folder path.",
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/FolderPathInput"
|
||||
}
|
||||
},
|
||||
"required": ["workspaceId"],
|
||||
@@ -2672,9 +2704,22 @@
|
||||
"maximum": 9007199254740991,
|
||||
"description": "Number of results returned.",
|
||||
"examples": [4]
|
||||
},
|
||||
"rerankerStatus": {
|
||||
"type": "string",
|
||||
"enum": ["not_requested", "skipped", "unavailable", "applied"],
|
||||
"description": "What the reranker did on this search. `applied` means it ordered the results, which carry `rerankerScore`. `unavailable` means it was attempted but could not complete, so results are in vector order with no `rerankerScore` — the search still succeeded, and is worth retrying. `skipped` means there was nothing to rank. `not_requested` means `rerankerEnabled` was absent or false.",
|
||||
"examples": ["applied"]
|
||||
}
|
||||
},
|
||||
"required": ["results", "query", "knowledgeBaseIds", "topK", "totalResults"],
|
||||
"required": [
|
||||
"results",
|
||||
"query",
|
||||
"knowledgeBaseIds",
|
||||
"topK",
|
||||
"totalResults",
|
||||
"rerankerStatus"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"title": "Knowledge search data",
|
||||
"description": "Results and execution context for a knowledge search."
|
||||
@@ -2707,9 +2752,22 @@
|
||||
},
|
||||
"operator": {
|
||||
"default": "eq",
|
||||
"description": "Comparison operator; valid operators depend on the field type.",
|
||||
"description": "Comparison operator; valid operators depend on the field type. Text tags accept eq, neq, contains, not_contains, starts_with, ends_with; number and date tags accept eq, neq, gt, gte, lt, lte, between; boolean tags accept eq, neq. An operator the tag's field type does not implement is rejected, never ignored.",
|
||||
"examples": ["eq"],
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"eq",
|
||||
"neq",
|
||||
"contains",
|
||||
"not_contains",
|
||||
"starts_with",
|
||||
"ends_with",
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
"between"
|
||||
]
|
||||
},
|
||||
"value": {
|
||||
"anyOf": [
|
||||
@@ -2727,7 +2785,7 @@
|
||||
"examples": ["billing"]
|
||||
},
|
||||
"valueTo": {
|
||||
"description": "Upper bound for the `between` operator.",
|
||||
"description": "Upper bound for the `between` operator, and required whenever that operator is used.",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
@@ -2739,6 +2797,7 @@
|
||||
}
|
||||
},
|
||||
"required": ["tagName", "value"],
|
||||
"additionalProperties": false,
|
||||
"title": "Knowledge search tag filter",
|
||||
"description": "A structured tag filter applied to knowledge search."
|
||||
},
|
||||
@@ -2748,6 +2807,7 @@
|
||||
"workspaceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace that owns the knowledge bases."
|
||||
},
|
||||
"knowledgeBaseIds": {
|
||||
@@ -2770,9 +2830,10 @@
|
||||
"examples": [["7c9e6679-7425-40de-944b-e07fc1f90ae7"]]
|
||||
},
|
||||
"query": {
|
||||
"description": "Natural-language query; required when tag filters are omitted.",
|
||||
"description": "Natural-language query; required when tag filters are omitted. At most 32768 characters — longer text exceeds the embedding model's per-input token ceiling and would be truncated before the billed search ran.",
|
||||
"examples": ["How do I reset my password?"],
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"maxLength": 32768
|
||||
},
|
||||
"topK": {
|
||||
"default": 10,
|
||||
@@ -2782,7 +2843,8 @@
|
||||
"maximum": 100
|
||||
},
|
||||
"tagFilters": {
|
||||
"description": "Structured tag filters. Supported across multiple knowledge bases, but each filtered tag must resolve to the same slot and field type in every knowledge base selected; a tag missing from one of them, or defined inconsistently across them, is rejected and those knowledge bases must be searched separately. A tag name defined in none of the selected knowledge bases is rejected, never ignored; list the available names with GET /api/v2/knowledge/{id}/tags.",
|
||||
"description": "Structured tag filters, at most 10 of them. Every filter must hold, including two that name the same tag: repeating one tag narrows the result rather than widening it, matching `GET /api/v2/knowledge/{id}/documents`. To match either of two values for one tag, issue a search per value. Each filtered tag must resolve to the same slot and field type in every knowledge base selected; one missing from any of them, or defined inconsistently across them, is rejected rather than ignored, and those knowledge bases must be searched separately. List the available names with `GET /api/v2/knowledge/{id}/tags`.",
|
||||
"maxItems": 10,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/V2KnowledgeSearchTagFilter"
|
||||
@@ -2802,11 +2864,12 @@
|
||||
]
|
||||
},
|
||||
"rerankerEnabled": {
|
||||
"description": "Re-order retrieved chunks with a reranking model before truncating to `topK`. Ignored for a tag-only search, which has no query to rank against. Reranking is billed as an additional search unit.",
|
||||
"description": "Re-order retrieved chunks with a reranking model before truncating to `topK`. Ignored for a tag-only search, and billed as an additional search unit. Reranking is best-effort — a provider failure falls back to vector ordering, so check `rerankerStatus` on the response.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"rerankerModel": {
|
||||
"description": "Reranking model to use; required for reranking to run.",
|
||||
"default": "rerank-v4.0-fast",
|
||||
"description": "Reranking model to use when `rerankerEnabled` is true. Defaults to `rerank-v4.0-fast`.",
|
||||
"type": "string",
|
||||
"enum": ["rerank-v4.0-pro", "rerank-v4.0-fast", "rerank-v3.5"]
|
||||
},
|
||||
@@ -2818,6 +2881,7 @@
|
||||
}
|
||||
},
|
||||
"required": ["workspaceId", "knowledgeBaseIds"],
|
||||
"additionalProperties": false,
|
||||
"title": "Search knowledge request",
|
||||
"description": "Knowledge bases, query, result limit, retrieval mode, and optional tag filters."
|
||||
},
|
||||
@@ -2864,7 +2928,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself."
|
||||
"description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change."
|
||||
}
|
||||
},
|
||||
"required": ["data", "nextCursor"],
|
||||
@@ -3007,7 +3071,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself."
|
||||
"description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself."
|
||||
}
|
||||
},
|
||||
"required": ["data", "nextCursor"],
|
||||
@@ -3062,6 +3126,7 @@
|
||||
"workspaceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace that owns the knowledge base."
|
||||
},
|
||||
"operation": {
|
||||
@@ -3324,7 +3389,7 @@
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "Signed URL to which the file bytes are uploaded."
|
||||
"description": "Signed URL to which the file bytes are uploaded. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. Success is `204` with an empty body. A failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response: `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. The URL is signed and self-describing — construct it from this field only, never by hand."
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
@@ -3413,6 +3478,7 @@
|
||||
"workspaceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace that owns the knowledge base."
|
||||
},
|
||||
"name": {
|
||||
@@ -3527,7 +3593,7 @@
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "Signed URL for this upload part."
|
||||
"description": "Signed URL for this upload part. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. Success is `204` with an empty body. A failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response: `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. The URL is signed and self-describing — construct it from this field only, never by hand."
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
@@ -3864,6 +3930,7 @@
|
||||
"workspaceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace that owns the knowledge base."
|
||||
},
|
||||
"filename": {
|
||||
@@ -3955,7 +4022,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"retryProcessing": {
|
||||
"description": "Requeue the document for processing. Send it alone: no other field may accompany it.",
|
||||
"description": "Requeue a failed or stuck document for processing. Send it alone — no other field may accompany it — and it answers with a queue acknowledgement rather than the document.",
|
||||
"type": "boolean",
|
||||
"const": true
|
||||
}
|
||||
@@ -3981,11 +4048,15 @@
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Canonical folder path used as the public folder identifier."
|
||||
"title": "Non-root folder path",
|
||||
"description": "Canonical folder path used as the public folder identifier.",
|
||||
"maxLength": 4096
|
||||
},
|
||||
"parentPath": {
|
||||
"type": "string",
|
||||
"description": "Canonical parent path; `/` is the root."
|
||||
"title": "Folder path",
|
||||
"description": "Canonical parent path; `/` is the root.",
|
||||
"maxLength": 4096
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
@@ -4022,13 +4093,13 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself."
|
||||
"description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change."
|
||||
}
|
||||
},
|
||||
"required": ["data", "nextCursor"],
|
||||
"additionalProperties": false,
|
||||
"title": "Knowledge folder list response",
|
||||
"description": "A cursor-paginated page of knowledge-base folders."
|
||||
"description": "The whole bounded set of knowledge-base folders, in one page."
|
||||
},
|
||||
"V2KnowledgeFolderResponse": {
|
||||
"type": "object",
|
||||
@@ -4043,17 +4114,24 @@
|
||||
"title": "Knowledge folder response",
|
||||
"description": "A single knowledge-base folder."
|
||||
},
|
||||
"NonRootFolderPathInput": {
|
||||
"title": "Non-root folder path input",
|
||||
"description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.",
|
||||
"maxLength": 4096,
|
||||
"type": "string"
|
||||
},
|
||||
"CreateKnowledgeFolderRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workspaceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace in which to create the folder."
|
||||
},
|
||||
"path": {
|
||||
"description": "Path of the folder to create.",
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/NonRootFolderPathInput"
|
||||
}
|
||||
},
|
||||
"required": ["workspaceId", "path"],
|
||||
@@ -4067,15 +4145,16 @@
|
||||
"workspaceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace containing the folder."
|
||||
},
|
||||
"path": {
|
||||
"description": "Current folder path.",
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/NonRootFolderPathInput"
|
||||
},
|
||||
"destinationPath": {
|
||||
"description": "New full path for the folder and its descendants.",
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/NonRootFolderPathInput"
|
||||
}
|
||||
},
|
||||
"required": ["workspaceId", "path", "destinationPath"],
|
||||
@@ -4088,7 +4167,9 @@
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Canonical path of the deleted folder."
|
||||
"title": "Folder path",
|
||||
"description": "Canonical path of the deleted folder.",
|
||||
"maxLength": 4096
|
||||
},
|
||||
"deleted": {
|
||||
"type": "boolean",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"get": {
|
||||
"operationId": "listLogs",
|
||||
"summary": "List Logs",
|
||||
"description": "List workflow execution logs for a workspace with filters, selectable detail, and opaque cursor pagination. This list predates the shared sort convention: it has no `sortBy` (the sort column is fixed to execution start time) and spells the direction `order` rather than `sortOrder`. Trace spans are stored separately from the log row and are pruned on their own retention schedule: `includeTraceSpans=true` on a run whose stored spans have aged out returns `traceSpans: []` rather than an error, so an empty array does not mean the run recorded no spans.",
|
||||
"description": "List workflow execution logs for a workspace with filters, selectable detail, and opaque cursor pagination. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override.",
|
||||
"tags": ["Logs"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -47,6 +47,7 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace whose execution logs should be returned."
|
||||
}
|
||||
},
|
||||
@@ -54,20 +55,20 @@
|
||||
"name": "workflowIds",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Comma-separated workflow identifiers to include.",
|
||||
"description": "Comma-separated workflow identifiers to include. An empty entry is rejected.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"description": "Comma-separated workflow identifiers to include."
|
||||
"description": "Comma-separated workflow identifiers to include. An empty entry is rejected."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "triggers",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Comma-separated trigger types to include.",
|
||||
"description": "Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"description": "Comma-separated trigger types to include."
|
||||
"description": "Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`."
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -85,64 +86,72 @@
|
||||
"name": "startDate",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected.",
|
||||
"description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
|
||||
"description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected."
|
||||
"description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "endDate",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected.",
|
||||
"description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
|
||||
"description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected."
|
||||
"description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "minDurationMs",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Minimum total execution duration in milliseconds.",
|
||||
"description": "Minimum total execution duration in milliseconds. Whole milliseconds from 0 to 2147483647; the stored duration is a 32-bit integer, so a fractional or out-of-range bound is rejected.",
|
||||
"schema": {
|
||||
"type": "number",
|
||||
"description": "Minimum total execution duration in milliseconds."
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 2147483647,
|
||||
"description": "Minimum total execution duration in milliseconds. Whole milliseconds from 0 to 2147483647; the stored duration is a 32-bit integer, so a fractional or out-of-range bound is rejected."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maxDurationMs",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Maximum total execution duration in milliseconds.",
|
||||
"description": "Maximum total execution duration in milliseconds. Whole milliseconds from 0 to 2147483647; the stored duration is a 32-bit integer, so a fractional or out-of-range bound is rejected.",
|
||||
"schema": {
|
||||
"type": "number",
|
||||
"description": "Maximum total execution duration in milliseconds."
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 2147483647,
|
||||
"description": "Maximum total execution duration in milliseconds. Whole milliseconds from 0 to 2147483647; the stored duration is a 32-bit integer, so a fractional or out-of-range bound is rejected."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "minCost",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Minimum execution cost in USD.",
|
||||
"description": "Minimum execution cost in USD, from 0 to 1000000. A run is never charged a negative amount, so a negative bound is rejected rather than treated as a filter that matches every run.",
|
||||
"schema": {
|
||||
"type": "number",
|
||||
"description": "Minimum execution cost in USD."
|
||||
"minimum": 0,
|
||||
"maximum": 1000000,
|
||||
"description": "Minimum execution cost in USD, from 0 to 1000000. A run is never charged a negative amount, so a negative bound is rejected rather than treated as a filter that matches every run."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maxCost",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Maximum execution cost in USD.",
|
||||
"description": "Maximum execution cost in USD, from 0 to 1000000. A run is never charged a negative amount, so a negative bound is rejected rather than treated as a filter that matches every run.",
|
||||
"schema": {
|
||||
"type": "number",
|
||||
"description": "Maximum execution cost in USD."
|
||||
"minimum": 0,
|
||||
"maximum": 1000000,
|
||||
"description": "Maximum execution cost in USD, from 0 to 1000000. A run is never charged a negative amount, so a negative bound is rejected rather than treated as a filter that matches every run."
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -159,21 +168,21 @@
|
||||
"name": "details",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Response detail level.",
|
||||
"description": "Response detail level. `full` adds the `workflow` summary to every item. `includeTraceSpans=true` and `includeFinalOutput=true` each imply `full`, so either one adds `workflow` even when `details=basic` is sent explicitly.",
|
||||
"schema": {
|
||||
"default": "basic",
|
||||
"type": "string",
|
||||
"enum": ["basic", "full"],
|
||||
"description": "Response detail level."
|
||||
"description": "Response detail level. `full` adds the `workflow` summary to every item. `includeTraceSpans=true` and `includeFinalOutput=true` each imply `full`, so either one adds `workflow` even when `details=basic` is sent explicitly."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "includeTraceSpans",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Whether to include block-level trace spans.",
|
||||
"description": "Whether to include block-level trace spans. Implies `details=full`. Spans are pruned on their own retention schedule, so a run whose spans have aged out returns `traceSpans: []` rather than an error.",
|
||||
"schema": {
|
||||
"description": "Whether to include block-level trace spans.",
|
||||
"description": "Whether to include block-level trace spans. Implies `details=full`. Spans are pruned on their own retention schedule, so a run whose spans have aged out returns `traceSpans: []` rather than an error.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
@@ -181,9 +190,9 @@
|
||||
"name": "includeFinalOutput",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Whether to include the final workflow output.",
|
||||
"description": "Whether to include the final workflow output. Implies `details=full`, so the `workflow` summary is present regardless of what `details` is set to.",
|
||||
"schema": {
|
||||
"description": "Whether to include the final workflow output.",
|
||||
"description": "Whether to include the final workflow output. Implies `details=full`, so the `workflow` summary is present regardless of what `details` is set to.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
@@ -195,8 +204,6 @@
|
||||
"schema": {
|
||||
"description": "Maximum log entries per page. Values outside 1–1000 are truncated and clamped into that range rather than rejected. Defaults to 100.",
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 1000,
|
||||
"default": 100
|
||||
}
|
||||
},
|
||||
@@ -204,9 +211,9 @@
|
||||
"name": "cursor",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Opaque cursor returned by the previous page.",
|
||||
"description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.",
|
||||
"schema": {
|
||||
"description": "Opaque cursor returned by the previous page.",
|
||||
"description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
@@ -215,12 +222,12 @@
|
||||
"name": "order",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Sort direction by execution start time. This operation deviates from the v2 `sortBy` + `sortOrder` convention: logs are sortable only by start time, so the direction is carried by this single `order` param and `sortBy`/`sortOrder` are not accepted.",
|
||||
"description": "Sort direction by execution start time. This list is sortable only by execution start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects.",
|
||||
"schema": {
|
||||
"default": "desc",
|
||||
"description": "Sort direction by execution start time. This list is sortable only by execution start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects.",
|
||||
"type": "string",
|
||||
"enum": ["desc", "asc"],
|
||||
"description": "Sort direction by execution start time. This operation deviates from the v2 `sortBy` + `sortOrder` convention: logs are sortable only by start time, so the direction is carried by this single `order` param and `sortBy`/`sortOrder` are not accepted."
|
||||
"enum": ["asc", "desc"]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -231,6 +238,8 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9._:-]+$",
|
||||
"description": "Exact run identifier to match."
|
||||
}
|
||||
},
|
||||
@@ -238,10 +247,10 @@
|
||||
"name": "folderPaths",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Comma-separated workflow folder paths to include.",
|
||||
"description": "Comma-separated workflow folder paths to include. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"description": "Comma-separated workflow folder paths to include."
|
||||
"description": "Comma-separated workflow folder paths to include. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error."
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -295,18 +304,20 @@
|
||||
"get": {
|
||||
"operationId": "getLog",
|
||||
"summary": "Get Log",
|
||||
"description": "Retrieve the diagnostic representation of a run, including its workflow snapshot, trace spans, final output, and cost. The returned `workflowState` snapshot has credential values redacted: OAuth credential references and secret (`password`) sub-block values are null, while `{{VAR}}` environment-variable references are preserved so consecutive snapshots stay diffable. Trace spans are stored separately from the log row and are pruned on their own retention schedule: a run whose stored spans have aged out returns `traceSpans: []` rather than an error, so an empty array does not mean the run recorded no spans.",
|
||||
"description": "Retrieve the diagnostic representation of a run, including its workflow snapshot, trace spans, final output, and cost. Trace spans are pruned on their own retention schedule, so an empty `traceSpans` array does not mean the run recorded none.",
|
||||
"tags": ["Logs"],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "runId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "The unique run identifier shared by lifecycle and diagnostic resources.",
|
||||
"description": "Unique workflow run identifier.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "The unique run identifier shared by lifecycle and diagnostic resources."
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9._:-]+$",
|
||||
"description": "Unique workflow run identifier."
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -363,7 +374,7 @@
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-API-Key",
|
||||
"description": "Your Sim API key, personal or workspace-scoped. Generate one from the Sim dashboard under Settings > API Keys. A workspace API key is not accepted everywhere: operations that act on behalf of a specific human — administrative reads, secret access, and irreversible or governance-affecting writes — always reject it, whatever role the key carries. Each such operation says so in its own description, and the rejection surfaces as `403` unless the operation conceals unauthorized resources, in which case it is reported as `404`. Use a personal API key for those."
|
||||
"description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description."
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
@@ -398,13 +409,13 @@
|
||||
}
|
||||
},
|
||||
"Retry-After": {
|
||||
"description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.",
|
||||
"description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991,
|
||||
"title": "Retry after",
|
||||
"description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset."
|
||||
"description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset."
|
||||
}
|
||||
},
|
||||
"X-Run-Id": {
|
||||
@@ -419,7 +430,7 @@
|
||||
},
|
||||
"responses": {
|
||||
"BadRequest": {
|
||||
"description": "The request is invalid.",
|
||||
"description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -449,7 +460,7 @@
|
||||
}
|
||||
},
|
||||
"Forbidden": {
|
||||
"description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.",
|
||||
"description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -479,7 +490,7 @@
|
||||
}
|
||||
},
|
||||
"RunIdConflict": {
|
||||
"description": "The run cannot be started. Two causes share this status, distinguished by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already associated with a different request, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has already reached the maximum workflow-to-workflow call depth.",
|
||||
"description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.",
|
||||
"headers": {
|
||||
"X-Run-Id": {
|
||||
"$ref": "#/components/headers/X-Run-Id"
|
||||
@@ -493,18 +504,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Gone": {
|
||||
"description": "The requested generated resource has expired.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/V2Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PayloadTooLarge": {
|
||||
"description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.",
|
||||
"description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -549,7 +550,7 @@
|
||||
}
|
||||
},
|
||||
"ClientClosedRequest": {
|
||||
"description": "The client closed the connection before the response was produced.",
|
||||
"description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -569,7 +570,7 @@
|
||||
}
|
||||
},
|
||||
"ServiceUnavailable": {
|
||||
"description": "A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.",
|
||||
"description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"$ref": "#/components/headers/Retry-After"
|
||||
@@ -600,7 +601,7 @@
|
||||
"description": "Human-readable explanation of the error."
|
||||
},
|
||||
"details": {
|
||||
"description": "Optional structured error details."
|
||||
"description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address."
|
||||
}
|
||||
},
|
||||
"required": ["code", "message"],
|
||||
@@ -661,7 +662,7 @@
|
||||
"failed",
|
||||
"cancelled"
|
||||
],
|
||||
"description": "Current execution status, reported as persisted. `redacting` is transient while run output is scrubbed. `paused` is reported only when a resume attempt did not run to completion and the run is waiting to be resumed again. **This differs from the run resources for the same run:** `GET /api/v2/workflows/{id}/runs` and `GET /api/v2/workflows/{id}/runs/{runId}` additionally report `paused` for a run held at a human-in-the-loop pause point, which this field reports as `pending`. Use the run resources when the pause state matters."
|
||||
"description": "Current execution status, reported as persisted. `redacting` is transient while run output is scrubbed. `paused` is reported only when a resume attempt did not complete; a run held at a human-in-the-loop pause point reads `pending` here, and `paused` on the workflow run resources. Use those when the pause state matters."
|
||||
},
|
||||
"level": {
|
||||
"type": "string",
|
||||
@@ -987,7 +988,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself."
|
||||
"description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself."
|
||||
}
|
||||
},
|
||||
"required": ["data", "nextCursor"],
|
||||
@@ -1057,7 +1058,7 @@
|
||||
"failed",
|
||||
"cancelled"
|
||||
],
|
||||
"description": "Current execution status, reported as persisted. `redacting` is transient while run output is scrubbed. `paused` is reported only when a resume attempt did not run to completion and the run is waiting to be resumed again. **This differs from the run resources for the same run:** `GET /api/v2/workflows/{id}/runs` and `GET /api/v2/workflows/{id}/runs/{runId}` additionally report `paused` for a run held at a human-in-the-loop pause point, which this field reports as `pending`. Use the run resources when the pause state matters."
|
||||
"description": "Current execution status, reported as persisted. `redacting` is transient while run output is scrubbed. `paused` is reported only when a resume attempt did not complete; a run held at a human-in-the-loop pause point reads `pending` here, and `paused` on the workflow run resources. Use those when the pause state matters."
|
||||
},
|
||||
"level": {
|
||||
"type": "string",
|
||||
@@ -1144,13 +1145,15 @@
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Canonical slash-prefixed folder path. `/` is the workspace root."
|
||||
"title": "Folder path",
|
||||
"description": "Canonical slash-prefixed folder path. `/` is the workspace root. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.",
|
||||
"maxLength": 4096
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Workflow folder path, or null when unavailable."
|
||||
"description": "Canonical folder path of the workflow, in the same form `folderPaths` accepts as a filter: `/` for a workflow at the workspace root. Null only when the path cannot be resolved — the folder has been deleted, or the workflow itself no longer exists."
|
||||
},
|
||||
"ownerEmail": {
|
||||
"anyOf": [
|
||||
@@ -1234,7 +1237,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Workflow graph snapshot captured for the run, with credential values redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters and every parameter without authoritative codec metadata are null; and `{{VAR}}` references in non-opaque fields are preserved. Null when no snapshot is retained."
|
||||
"description": "Workflow graph snapshot captured for the run, or null when none is retained. Credential-bearing values are redacted to null: `oauth-input`, `password: true`, table sub-block values, sensitive nested tool parameters, and any parameter without authoritative codec metadata. `{{VAR}}` references in non-opaque fields are preserved."
|
||||
},
|
||||
"traceSpans": {
|
||||
"type": "array",
|
||||
|
||||
+1082
-112
File diff suppressed because it is too large
Load Diff
+576
-155
File diff suppressed because it is too large
Load Diff
+158
-115
@@ -40,7 +40,7 @@
|
||||
"get": {
|
||||
"operationId": "listWorkflows",
|
||||
"summary": "List Workflows",
|
||||
"description": "List workflows in a workspace with folder and deployment filters, search, sorting, and opaque cursor pagination. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.",
|
||||
"description": "List workflows in a workspace with folder and deployment filters, search, sorting, and opaque cursor pagination. A workspace folder tree over 10,000 folders is a `413`.",
|
||||
"tags": ["Workflows"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -51,6 +51,7 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace whose workflows should be listed."
|
||||
}
|
||||
},
|
||||
@@ -58,10 +59,10 @@
|
||||
"name": "folderPath",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Restrict results to workflows in this folder path.",
|
||||
"description": "Restrict results to workflows in this folder path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.",
|
||||
"schema": {
|
||||
"description": "Restrict results to workflows in this folder path.",
|
||||
"type": "string"
|
||||
"description": "Restrict results to workflows in this folder path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.",
|
||||
"$ref": "#/components/schemas/FolderPathInput"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -91,9 +92,9 @@
|
||||
"name": "cursor",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Opaque cursor returned by the previous page.",
|
||||
"description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.",
|
||||
"schema": {
|
||||
"description": "Opaque cursor returned by the previous page.",
|
||||
"description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
@@ -102,9 +103,9 @@
|
||||
"name": "search",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Case-insensitive substring search on the resource name.",
|
||||
"description": "Case-insensitive substring match against the resource name.",
|
||||
"schema": {
|
||||
"description": "Case-insensitive substring search on the resource name.",
|
||||
"description": "Case-insensitive substring match against the resource name.",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200
|
||||
@@ -114,10 +115,10 @@
|
||||
"name": "sortBy",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Field used to sort the result.",
|
||||
"description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.",
|
||||
"schema": {
|
||||
"default": "position",
|
||||
"description": "Field used to sort the result.",
|
||||
"description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.",
|
||||
"type": "string",
|
||||
"enum": ["position", "name", "createdAt", "updatedAt", "runCount"]
|
||||
}
|
||||
@@ -186,7 +187,7 @@
|
||||
"post": {
|
||||
"operationId": "createWorkflowV2",
|
||||
"summary": "Create Workflow",
|
||||
"description": "Create a workflow in a workspace root or canonical workflow folder. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.",
|
||||
"description": "Create a workflow in a workspace root or canonical workflow folder. A workspace folder tree over 10,000 folders is a `413`.",
|
||||
"tags": ["Workflows"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -258,7 +259,7 @@
|
||||
"get": {
|
||||
"operationId": "getWorkflow",
|
||||
"summary": "Get Workflow",
|
||||
"description": "Get a workflow with its variables and deployed API-trigger inputs. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.",
|
||||
"description": "Get a workflow with its variables and deployed API-trigger inputs. A workspace folder tree over 10,000 folders is a `413`.",
|
||||
"tags": ["Workflows"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -325,7 +326,7 @@
|
||||
"patch": {
|
||||
"operationId": "updateWorkflowV2",
|
||||
"summary": "Update Workflow",
|
||||
"description": "Rename, describe, or move a workflow to a canonical folder path. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.",
|
||||
"description": "Rename, describe, or move a workflow to a canonical folder path. A workspace folder tree over 10,000 folders is a `413`.",
|
||||
"tags": ["Workflows"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -510,9 +511,9 @@
|
||||
"name": "cursor",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Opaque cursor returned by the previous page.",
|
||||
"description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.",
|
||||
"schema": {
|
||||
"description": "Opaque cursor returned by the previous page.",
|
||||
"description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
@@ -590,7 +591,7 @@
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 9007199254740991,
|
||||
"maximum": 2147483647,
|
||||
"description": "Numeric deployment version.",
|
||||
"examples": [3]
|
||||
}
|
||||
@@ -646,7 +647,7 @@
|
||||
"get": {
|
||||
"operationId": "getWorkflowDeployment",
|
||||
"summary": "Get Workflow Deployment",
|
||||
"description": "Read the current deployment state of a workflow: whether a version is live, when it went live, the most recent deployment attempt with its readiness and failure payload, and whether the editable draft has since diverged from the live version. This is the only place `needsRedeployment` is published — the deploy, undeploy, and rollback responses cannot carry it, because they answer at the moment the draft and the live version are equal.",
|
||||
"description": "Read the current deployment state of a workflow: whether a version is live, when it went live, the most recent deployment attempt with its readiness and failure payload, and whether the editable draft has since diverged from the live version. This is the only operation that publishes `needsRedeployment`.",
|
||||
"tags": ["Workflows"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -712,7 +713,7 @@
|
||||
"post": {
|
||||
"operationId": "deployWorkflow",
|
||||
"summary": "Deploy Workflow",
|
||||
"description": "Create and asynchronously activate a deployment version. This request is not idempotent: it accepts no idempotency key and every call mints a new deployment version, so retrying after a timeout creates a second version rather than returning the first. The response carries `latestDeploymentAttempt` for the accepted attempt, but `GET /workflows/{id}` does not expose that field — poll activation with `isDeployed` and `deployedAt` on the workflow, or with `isActive` on `GET /workflows/{id}/versions`. Returns 409 when the deployment would conflict with an existing webhook path. A workspace API key cannot call this operation. Because unauthorized resources are concealed, the rejection is reported as `404` rather than `403`; use a personal API key.",
|
||||
"description": "Create and asynchronously activate a deployment version. Not idempotent: every call mints a new version, so a retry after a timeout creates a second one. A deployment that would conflict with an existing webhook path is a `409`. A workspace API key is rejected with `403`; use a personal API key.",
|
||||
"tags": ["Workflows"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -796,7 +797,7 @@
|
||||
"delete": {
|
||||
"operationId": "undeployWorkflow",
|
||||
"summary": "Undeploy Workflow",
|
||||
"description": "Deactivate the currently serving workflow version. A workspace API key cannot call this operation. Because unauthorized resources are concealed, the rejection is reported as `404` rather than `403`; use a personal API key.",
|
||||
"description": "Deactivate the currently serving workflow version. A workspace API key is rejected with `403`; use a personal API key.",
|
||||
"tags": ["Workflows"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -865,7 +866,7 @@
|
||||
"post": {
|
||||
"operationId": "rollbackWorkflow",
|
||||
"summary": "Rollback Workflow",
|
||||
"description": "Asynchronously reactivate a previous deployment version, selecting the preceding active version when no version is supplied. A workspace API key cannot call this operation. Because unauthorized resources are concealed, the rejection is reported as `404` rather than `403`; use a personal API key.",
|
||||
"description": "Asynchronously reactivate a previous deployment version, selecting the preceding active version when no version is supplied. A workspace API key is rejected with `403`; use a personal API key.",
|
||||
"tags": ["Workflows"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -926,6 +927,9 @@
|
||||
"404": {
|
||||
"$ref": "#/components/responses/NotFound"
|
||||
},
|
||||
"409": {
|
||||
"$ref": "#/components/responses/Conflict"
|
||||
},
|
||||
"413": {
|
||||
"$ref": "#/components/responses/PayloadTooLarge"
|
||||
},
|
||||
@@ -948,7 +952,7 @@
|
||||
"get": {
|
||||
"operationId": "exportWorkflow",
|
||||
"summary": "Export Workflow",
|
||||
"description": "Export a portable, secret-sanitized workflow. Workspace-scoped bindings must be selected again after import. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.",
|
||||
"description": "Export a portable, secret-sanitized workflow. Workspace-scoped bindings must be selected again after import. Exporting records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return. A workspace folder tree over 10,000 folders is a `413`.",
|
||||
"tags": ["Workflows"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1017,7 +1021,7 @@
|
||||
"post": {
|
||||
"operationId": "importWorkflow",
|
||||
"summary": "Import Workflow",
|
||||
"description": "Create a workflow from a portable export object, bare state, or JSON string.",
|
||||
"description": "Create a workflow from a portable export object, bare state, or JSON string. A workspace folder tree over 10,000 folders is a `413`.",
|
||||
"tags": ["Workflows"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -1089,7 +1093,7 @@
|
||||
"post": {
|
||||
"operationId": "executeWorkflowV2",
|
||||
"summary": "Execute Workflow",
|
||||
"description": "Execute a deployed workflow synchronously, asynchronously, or as Server-Sent Events. Public workflows permit anonymous synchronous and streaming execution; asynchronous execution requires an API key. A synchronous run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"` rather than an HTTP error, so branch on `status`. The optional `X-Run-Id` header is a one-shot uniqueness claim, not an idempotency key: reusing a value returns 409 with `error.details.code: \"RUN_ID_CONFLICT\"` and never replays the earlier run. Option constraints — each is a 400: (1) `async: true` requires an API key; anonymous public-workflow callers may only execute synchronously or as a stream. (2) `async` and `stream` cannot both be true. (3) `executionTimeoutSeconds` is accepted only when `async: true`. (4) `async: true` rejects every streaming and output-shaping option — `selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, and `base64MaxBytes`. (5) `includeThinking` and `includeToolCalls` require `stream: true`. (6) `includeThinking` and `includeToolCalls` require the `X-Sim-Stream-Protocol: agent-events-v1` request header, which declares that the client understands agent-event frames.",
|
||||
"description": "Execute a deployed workflow synchronously, asynchronously, or as Server-Sent Events. Public workflows permit anonymous synchronous and streaming execution; asynchronous execution requires an API key. A synchronous run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"` rather than an HTTP error, so branch on `status`. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.",
|
||||
"tags": ["Workflows"],
|
||||
"security": [
|
||||
{
|
||||
@@ -1114,9 +1118,9 @@
|
||||
"name": "x-run-id",
|
||||
"in": "header",
|
||||
"required": false,
|
||||
"description": "Caller-supplied run identifier, available only to API-key callers. This is a one-shot uniqueness claim, NOT an idempotency key: the first request to use a value starts a run, and any later request reusing it fails with 409 and `error.details.code: \"RUN_ID_CONFLICT\"` instead of replaying the original result. To retry safely, generate a fresh value per attempt and reconcile duplicates yourself, or omit the header and let the server allocate the run identifier.",
|
||||
"description": "Caller-supplied run identifier, available only to API-key callers. A one-shot uniqueness claim, NOT an idempotency key: reusing a value fails with `409` and `error.details.code: \"RUN_ID_CONFLICT\"` rather than replaying the original result. To retry safely, send a fresh value per attempt, or omit the header and let the server allocate one.",
|
||||
"schema": {
|
||||
"description": "Caller-supplied run identifier, available only to API-key callers. This is a one-shot uniqueness claim, NOT an idempotency key: the first request to use a value starts a run, and any later request reusing it fails with 409 and `error.details.code: \"RUN_ID_CONFLICT\"` instead of replaying the original result. To retry safely, generate a fresh value per attempt and reconcile duplicates yourself, or omit the header and let the server allocate the run identifier.",
|
||||
"description": "Caller-supplied run identifier, available only to API-key callers. A one-shot uniqueness claim, NOT an idempotency key: reusing a value fails with `409` and `error.details.code: \"RUN_ID_CONFLICT\"` rather than replaying the original result. To retry safely, send a fresh value per attempt, or omit the header and let the server allocate one.",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
@@ -1128,16 +1132,16 @@
|
||||
"name": "x-sim-via",
|
||||
"in": "header",
|
||||
"required": false,
|
||||
"description": "Comma-separated workflow identifiers describing the workflow-to-workflow call chain that led to this request. Each hop appends its own workflow id, and Sim sets it automatically when one workflow calls another; supply it yourself only when relaying an existing chain. A chain already at the maximum depth is rejected with 409 and `error.details.code: \"CALL_CHAIN_DEPTH_EXCEEDED\"`, which is how runaway recursion between workflows is stopped.",
|
||||
"description": "Comma-separated workflow identifiers naming the workflow-to-workflow call chain that led to this request. Each hop appends its own workflow id, and Sim sets it automatically; supply it yourself only when relaying an existing chain. A chain at the maximum depth is rejected with `409` and `error.details.code: \"CALL_CHAIN_DEPTH_EXCEEDED\"`.",
|
||||
"schema": {
|
||||
"description": "Comma-separated workflow identifiers describing the workflow-to-workflow call chain that led to this request. Each hop appends its own workflow id, and Sim sets it automatically when one workflow calls another; supply it yourself only when relaying an existing chain. A chain already at the maximum depth is rejected with 409 and `error.details.code: \"CALL_CHAIN_DEPTH_EXCEEDED\"`, which is how runaway recursion between workflows is stopped.",
|
||||
"description": "Comma-separated workflow identifiers naming the workflow-to-workflow call chain that led to this request. Each hop appends its own workflow id, and Sim sets it automatically; supply it yourself only when relaying an existing chain. A chain at the maximum depth is rejected with `409` and `error.details.code: \"CALL_CHAIN_DEPTH_EXCEEDED\"`.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"description": "Input and execution-mode options for a deployed workflow. Option constraints — each is a 400: (1) `async: true` requires an API key; anonymous public-workflow callers may only execute synchronously or as a stream. (2) `async` and `stream` cannot both be true. (3) `executionTimeoutSeconds` is accepted only when `async: true`. (4) `async: true` rejects every streaming and output-shaping option — `selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, and `base64MaxBytes`. (5) `includeThinking` and `includeToolCalls` require `stream: true`. (6) `includeThinking` and `includeToolCalls` require the `X-Sim-Stream-Protocol: agent-events-v1` request header, which declares that the client understands agent-event frames.",
|
||||
"description": "Input and execution-mode options for a deployed workflow. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -1240,7 +1244,7 @@
|
||||
"get": {
|
||||
"operationId": "listWorkflowRunsV2",
|
||||
"summary": "List Workflow Runs",
|
||||
"description": "List recorded runs of a workflow with filtering and opaque cursor pagination. Ordering deviates from the v2 `sortBy` + `sortOrder` convention: runs are sortable only by start time, so direction is carried by the single `order` param.",
|
||||
"description": "List recorded runs of a workflow with filtering and opaque cursor pagination. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override.",
|
||||
"tags": ["Workflow Runs"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1281,24 +1285,24 @@
|
||||
"name": "startDate",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected.",
|
||||
"description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
|
||||
"description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected."
|
||||
"description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "endDate",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected.",
|
||||
"description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
|
||||
"description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected."
|
||||
"description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant."
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1318,9 +1322,9 @@
|
||||
"name": "cursor",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Opaque cursor returned by the previous page.",
|
||||
"description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.",
|
||||
"schema": {
|
||||
"description": "Opaque cursor returned by the previous page.",
|
||||
"description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
@@ -1329,10 +1333,10 @@
|
||||
"name": "order",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Sort direction by run start time. This operation deviates from the v2 `sortBy` + `sortOrder` convention: runs are sortable only by start time, so the direction is carried by this single `order` param and `sortBy`/`sortOrder` are not accepted.",
|
||||
"description": "Sort direction by run start time. This list is sortable only by run start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects.",
|
||||
"schema": {
|
||||
"default": "desc",
|
||||
"description": "Sort direction by run start time. This operation deviates from the v2 `sortBy` + `sortOrder` convention: runs are sortable only by start time, so the direction is carried by this single `order` param and `sortBy`/`sortOrder` are not accepted.",
|
||||
"description": "Sort direction by run start time. This list is sortable only by run start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects.",
|
||||
"type": "string",
|
||||
"enum": ["asc", "desc"]
|
||||
}
|
||||
@@ -1420,9 +1424,9 @@
|
||||
"name": "includeOutput",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Include final and block outputs when true.",
|
||||
"description": "Include the final workflow output when true. It does not gate `blockOutputs`, which `selectedOutputs` selects on its own.",
|
||||
"schema": {
|
||||
"description": "Include final and block outputs when true.",
|
||||
"description": "Include the final workflow output when true. It does not gate `blockOutputs`, which `selectedOutputs` selects on its own.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
@@ -1430,9 +1434,9 @@
|
||||
"name": "selectedOutputs",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Comma-separated block output references to include.",
|
||||
"description": "Comma-separated block output references to include, as `blockId` or `blockId.path`. Block *names* are not resolved here — unlike the execute request, this resource reads a recorded run and matches ids only, so a name selects nothing and yields an empty `blockOutputs`.",
|
||||
"schema": {
|
||||
"description": "Comma-separated block output references to include.",
|
||||
"description": "Comma-separated block output references to include, as `blockId` or `blockId.path`. Block *names* are not resolved here — unlike the execute request, this resource reads a recorded run and matches ids only, so a name selects nothing and yields an empty `blockOutputs`.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
@@ -1616,7 +1620,7 @@
|
||||
"post": {
|
||||
"operationId": "cancelRunV2",
|
||||
"summary": "Cancel Workflow Run",
|
||||
"description": "Request cancellation of a running, queued, or paused workflow run. Cancelling a run that has already reached a terminal state succeeds with no effect rather than returning an error. The `reason` field is present on every response, including full successes — `recorded` is the success value; it is not a partial-failure marker. A run produced by a table workflow group is a 409 when its cell can no longer accept the cancellation, because the run and its cell must reach the cancelled state together.",
|
||||
"description": "Request cancellation of a running, queued, or paused workflow run. Cancelling a run already in a terminal state succeeds with no effect. A run produced by a table workflow group is a `409` when its cell can no longer accept the cancellation.",
|
||||
"tags": ["Workflow Runs"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1698,7 +1702,7 @@
|
||||
"get": {
|
||||
"operationId": "listWorkflowsFolders",
|
||||
"summary": "List Workflow Folders",
|
||||
"description": "List canonical workflow folders in a workspace. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch.",
|
||||
"description": "List canonical workflow folders in a workspace. The bounded set is returned in one page; `nextCursor` is always null. A workspace folder tree over 10,000 folders is a `413`.",
|
||||
"tags": ["Workflows"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1709,6 +1713,7 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace whose folders should be listed."
|
||||
}
|
||||
},
|
||||
@@ -1719,7 +1724,7 @@
|
||||
"description": "Restrict results to direct children of this parent path.",
|
||||
"schema": {
|
||||
"description": "Restrict results to direct children of this parent path.",
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/FolderPathInput"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1738,10 +1743,10 @@
|
||||
"name": "sortBy",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Field used to sort the result.",
|
||||
"description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.",
|
||||
"schema": {
|
||||
"default": "name",
|
||||
"description": "Field used to sort the result.",
|
||||
"description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.",
|
||||
"type": "string",
|
||||
"enum": ["name", "createdAt", "updatedAt"]
|
||||
}
|
||||
@@ -1810,7 +1815,7 @@
|
||||
"post": {
|
||||
"operationId": "createWorkflowsFolder",
|
||||
"summary": "Create Workflow Folder",
|
||||
"description": "Create a canonical workflow folder in a workspace.",
|
||||
"description": "Create a canonical workflow folder in a workspace. A workspace folder tree over 10,000 folders is a `413`.",
|
||||
"tags": ["Workflows"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -1880,7 +1885,7 @@
|
||||
"patch": {
|
||||
"operationId": "relocateWorkflowsFolder",
|
||||
"summary": "Rename or Move Workflow Folder",
|
||||
"description": "Rename or move a workflow folder and its descendants to a canonical path.",
|
||||
"description": "Rename or move a workflow folder and its descendants to a canonical path. A workspace folder tree over 10,000 folders is a `413`.",
|
||||
"tags": ["Workflows"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -1961,6 +1966,7 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace containing the folder."
|
||||
}
|
||||
},
|
||||
@@ -1971,16 +1977,30 @@
|
||||
"description": "Path of the folder to delete.",
|
||||
"schema": {
|
||||
"description": "Path of the folder to delete.",
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/NonRootFolderPathInput"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "recursive",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Delete nested files and folders when true.",
|
||||
"description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.",
|
||||
"schema": {
|
||||
"description": "Delete nested files and folders when true.",
|
||||
"description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.",
|
||||
"enum": [
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
"on",
|
||||
"y",
|
||||
"enabled",
|
||||
"false",
|
||||
"0",
|
||||
"no",
|
||||
"off",
|
||||
"n",
|
||||
"disabled"
|
||||
],
|
||||
"default": "false",
|
||||
"type": "string"
|
||||
}
|
||||
@@ -2048,7 +2068,7 @@
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-API-Key",
|
||||
"description": "Your Sim API key, personal or workspace-scoped. Generate one from the Sim dashboard under Settings > API Keys. A workspace API key is not accepted everywhere: operations that act on behalf of a specific human — administrative reads, secret access, and irreversible or governance-affecting writes — always reject it, whatever role the key carries. Each such operation says so in its own description, and the rejection surfaces as `403` unless the operation conceals unauthorized resources, in which case it is reported as `404`. Use a personal API key for those."
|
||||
"description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description."
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
@@ -2083,13 +2103,13 @@
|
||||
}
|
||||
},
|
||||
"Retry-After": {
|
||||
"description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.",
|
||||
"description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991,
|
||||
"title": "Retry after",
|
||||
"description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset."
|
||||
"description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset."
|
||||
}
|
||||
},
|
||||
"X-Run-Id": {
|
||||
@@ -2104,7 +2124,7 @@
|
||||
},
|
||||
"responses": {
|
||||
"BadRequest": {
|
||||
"description": "The request is invalid.",
|
||||
"description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -2134,7 +2154,7 @@
|
||||
}
|
||||
},
|
||||
"Forbidden": {
|
||||
"description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.",
|
||||
"description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -2164,7 +2184,7 @@
|
||||
}
|
||||
},
|
||||
"RunIdConflict": {
|
||||
"description": "The run cannot be started. Two causes share this status, distinguished by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already associated with a different request, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has already reached the maximum workflow-to-workflow call depth.",
|
||||
"description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.",
|
||||
"headers": {
|
||||
"X-Run-Id": {
|
||||
"$ref": "#/components/headers/X-Run-Id"
|
||||
@@ -2178,18 +2198,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Gone": {
|
||||
"description": "The requested generated resource has expired.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/V2Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PayloadTooLarge": {
|
||||
"description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.",
|
||||
"description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -2234,7 +2244,7 @@
|
||||
}
|
||||
},
|
||||
"ClientClosedRequest": {
|
||||
"description": "The client closed the connection before the response was produced.",
|
||||
"description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -2254,7 +2264,7 @@
|
||||
}
|
||||
},
|
||||
"ServiceUnavailable": {
|
||||
"description": "A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.",
|
||||
"description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"$ref": "#/components/headers/Retry-After"
|
||||
@@ -2285,7 +2295,7 @@
|
||||
"description": "Human-readable explanation of the error."
|
||||
},
|
||||
"details": {
|
||||
"description": "Optional structured error details."
|
||||
"description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address."
|
||||
}
|
||||
},
|
||||
"required": ["code", "message"],
|
||||
@@ -2306,6 +2316,12 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"FolderPathInput": {
|
||||
"title": "Folder path input",
|
||||
"description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.",
|
||||
"maxLength": 4096,
|
||||
"type": "string"
|
||||
},
|
||||
"WorkflowListItem": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2332,7 +2348,9 @@
|
||||
},
|
||||
"folderPath": {
|
||||
"type": "string",
|
||||
"title": "Folder path",
|
||||
"description": "Canonical containing-folder path; `/` is the workspace root.",
|
||||
"maxLength": 4096,
|
||||
"examples": ["/Operations"]
|
||||
},
|
||||
"workspaceId": {
|
||||
@@ -2359,7 +2377,7 @@
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991,
|
||||
"description": "Total recorded workflow runs."
|
||||
"description": "Runs that finished successfully. Failed, cancelled, and paused runs are not counted, and the counter is never reduced when a run ages out of log retention — so it does not match the size of `GET /api/v2/workflows/{id}/runs`, in either direction."
|
||||
},
|
||||
"lastRunAt": {
|
||||
"anyOf": [
|
||||
@@ -2370,7 +2388,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "ISO 8601 timestamp of the latest run, or null when never run.",
|
||||
"description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.",
|
||||
"format": "date-time"
|
||||
},
|
||||
"createdAt": {
|
||||
@@ -2420,7 +2438,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself."
|
||||
"description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself."
|
||||
}
|
||||
},
|
||||
"required": ["data", "nextCursor"],
|
||||
@@ -2484,6 +2502,7 @@
|
||||
"workspaceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace in which to create the workflow."
|
||||
},
|
||||
"name": {
|
||||
@@ -2505,8 +2524,7 @@
|
||||
]
|
||||
},
|
||||
"folderPath": {
|
||||
"description": "Folder path. A missing leading slash is normalized before validation.",
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/FolderPathInput"
|
||||
}
|
||||
},
|
||||
"required": ["workspaceId", "name"],
|
||||
@@ -2561,7 +2579,9 @@
|
||||
},
|
||||
"folderPath": {
|
||||
"type": "string",
|
||||
"title": "Folder path",
|
||||
"description": "Canonical containing-folder path; `/` is the workspace root.",
|
||||
"maxLength": 4096,
|
||||
"examples": ["/Operations"]
|
||||
},
|
||||
"workspaceId": {
|
||||
@@ -2588,7 +2608,7 @@
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991,
|
||||
"description": "Total recorded workflow runs."
|
||||
"description": "Runs that finished successfully. Failed, cancelled, and paused runs are not counted, and the counter is never reduced when a run ages out of log retention — so it does not match the size of `GET /api/v2/workflows/{id}/runs`, in either direction."
|
||||
},
|
||||
"lastRunAt": {
|
||||
"anyOf": [
|
||||
@@ -2599,7 +2619,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "ISO 8601 timestamp of the latest run, or null when never run.",
|
||||
"description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.",
|
||||
"format": "date-time"
|
||||
},
|
||||
"createdAt": {
|
||||
@@ -2734,7 +2754,7 @@
|
||||
},
|
||||
"folderPath": {
|
||||
"description": "Destination folder path; `/` moves the workflow to the workspace root.",
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/FolderPathInput"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
@@ -2872,7 +2892,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself."
|
||||
"description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself."
|
||||
}
|
||||
},
|
||||
"required": ["data", "nextCursor"],
|
||||
@@ -2948,7 +2968,7 @@
|
||||
"format": "date-time"
|
||||
},
|
||||
"state": {
|
||||
"description": "Deployed workflow graph snapshot pinned by this version. Credential-bearing values are redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters and every parameter without authoritative codec metadata are null.",
|
||||
"description": "Deployed workflow graph snapshot pinned by this version, with credential-bearing values redacted to null: `oauth-input`, `password: true`, table sub-block values, sensitive nested tool parameters, and any parameter without authoritative codec metadata.",
|
||||
"$ref": "#/components/schemas/DeployedWorkflowState"
|
||||
}
|
||||
},
|
||||
@@ -3321,7 +3341,7 @@
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"title": "Deploy result",
|
||||
"description": "Deployment attempt accepted for processing. Activation is asynchronous; `latestDeploymentAttempt` on this response is the attempt handle. The request is NOT idempotent — every POST mints a new deployment version, so a retry after a timeout creates a second version rather than returning the first. `latestDeploymentAttempt` is returned only here: `GET /workflows/{id}` does not carry it, so poll activation with `isDeployed` and `deployedAt` on the workflow, or with `isActive` on `GET /workflows/{id}/versions`."
|
||||
"description": "Deployment attempt accepted for processing. Activation is asynchronous, and `latestDeploymentAttempt` is the attempt handle — returned only here. Poll activation with `isDeployed` and `deployedAt` on the workflow, or `isActive` on `GET /workflows/{id}/versions`."
|
||||
},
|
||||
"DeployWorkflowResponse": {
|
||||
"type": "object",
|
||||
@@ -3394,7 +3414,8 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"UndeployResult": {
|
||||
"type": "object",
|
||||
@@ -3620,7 +3641,8 @@
|
||||
"minimum": 1,
|
||||
"maximum": 2147483647
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"WorkflowExportPayload": {
|
||||
"type": "object",
|
||||
@@ -3670,7 +3692,9 @@
|
||||
},
|
||||
"folderPath": {
|
||||
"type": "string",
|
||||
"description": "Canonical containing-folder path; `/` is the workspace root."
|
||||
"title": "Folder path",
|
||||
"description": "Canonical containing-folder path; `/` is the workspace root.",
|
||||
"maxLength": 4096
|
||||
}
|
||||
},
|
||||
"required": ["id", "name", "description", "workspaceId", "folderPath"],
|
||||
@@ -3748,7 +3772,9 @@
|
||||
},
|
||||
"folderPath": {
|
||||
"type": "string",
|
||||
"description": "Canonical containing-folder path."
|
||||
"title": "Folder path",
|
||||
"description": "Canonical containing-folder path.",
|
||||
"maxLength": 4096
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
@@ -3806,6 +3832,7 @@
|
||||
"workspaceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace in which to import the workflow."
|
||||
},
|
||||
"workflow": {
|
||||
@@ -3825,7 +3852,7 @@
|
||||
},
|
||||
"folderPath": {
|
||||
"description": "Destination folder path; omit for the workspace root.",
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/FolderPathInput"
|
||||
},
|
||||
"name": {
|
||||
"description": "Override for the imported workflow name.",
|
||||
@@ -3860,21 +3887,20 @@
|
||||
"INVALID_INPUT",
|
||||
"BLOCK_EXECUTION_FAILED",
|
||||
"CHILD_WORKFLOW_FAILED",
|
||||
"OUTPUT_TOO_LARGE",
|
||||
"EXECUTION_FAILED"
|
||||
],
|
||||
"description": "Stable machine-readable execution failure code."
|
||||
"description": "Stable machine-readable execution failure code. `BLOCK_EXECUTION_FAILED` and `CHILD_WORKFLOW_FAILED` are reported only where block attribution is available; elsewhere a block-level failure is reported as `EXECUTION_FAILED`."
|
||||
},
|
||||
"blockId": {
|
||||
"description": "Identifier of the failing block, when attributable.",
|
||||
"description": "Identifier of the failing block. Present on the synchronous execute response only; the polled run resource and the resume response cannot attribute a block.",
|
||||
"type": "string"
|
||||
},
|
||||
"blockName": {
|
||||
"description": "Display name of the failing block.",
|
||||
"description": "Display name of the failing block. Present on the synchronous execute response only.",
|
||||
"type": "string"
|
||||
},
|
||||
"blockType": {
|
||||
"description": "Integration or block type that failed.",
|
||||
"description": "Integration or block type that failed. Present on the synchronous execute response only.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
@@ -3936,7 +3962,7 @@
|
||||
"required": ["runId", "workflowId", "status", "output", "error"],
|
||||
"additionalProperties": false,
|
||||
"title": "Workflow run result",
|
||||
"description": "Synchronous workflow run output and in-band execution status. Run failures are reported in band, not as HTTP errors — a synchronous run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"`, so always branch on `status` rather than on the HTTP status alone."
|
||||
"description": "Synchronous workflow run output and in-band execution status. Run failures are reported in band, not as HTTP errors — a run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"`, so branch on `status`."
|
||||
},
|
||||
"ExecuteWorkflowSyncResponse": {
|
||||
"type": "object",
|
||||
@@ -4029,7 +4055,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"executionTimeoutSeconds": {
|
||||
"description": "Requested server-side timeout for an asynchronous run, in seconds. This is an upper bound on the request, not the effective timeout: the run uses the smaller of this value and the account plan's execution timeout, so requesting more than the plan allows silently yields the plan timeout with no warning. Rejected with 400 unless `async` is true.",
|
||||
"description": "Requested server-side timeout for an asynchronous run, in seconds. An upper bound, not the effective timeout: the run uses the smaller of this value and the plan's execution timeout, so requesting more than the plan allows silently yields the plan timeout. Rejected with `400` unless `async` is true.",
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 604800
|
||||
@@ -4059,11 +4085,11 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"includeFileBase64": {
|
||||
"description": "Inline eligible output files as base64 content.",
|
||||
"description": "Inline eligible output files as base64 content. Rejected when `async` is true.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"base64MaxBytes": {
|
||||
"description": "Maximum total bytes of file content to inline as base64.",
|
||||
"description": "Maximum total bytes of file content to inline as base64. Rejected when `async` is true.",
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 10485760
|
||||
@@ -4071,7 +4097,7 @@
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"title": "Execute workflow request",
|
||||
"description": "Input and execution-mode options for a deployed workflow. Option constraints — each is a 400: (1) `async: true` requires an API key; anonymous public-workflow callers may only execute synchronously or as a stream. (2) `async` and `stream` cannot both be true. (3) `executionTimeoutSeconds` is accepted only when `async: true`. (4) `async: true` rejects every streaming and output-shaping option — `selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, and `base64MaxBytes`. (5) `includeThinking` and `includeToolCalls` require `stream: true`. (6) `includeThinking` and `includeToolCalls` require the `X-Sim-Stream-Protocol: agent-events-v1` request header, which declares that the client understands agent-event frames.",
|
||||
"description": "Input and execution-mode options for a deployed workflow. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.",
|
||||
"examples": [
|
||||
{
|
||||
"input": {
|
||||
@@ -4118,7 +4144,7 @@
|
||||
"failed",
|
||||
"cancelled"
|
||||
],
|
||||
"description": "Current or terminal run status. `redacting` is transient, reported while the output of a finished run is being scrubbed. `paused` means the run is not executing and is waiting to be resumed: either held at a human-in-the-loop pause point, or left paused because a resume attempt did not run to completion. The status alone does not say which. On the single-run response `paused.automaticResumeWaitingReason` distinguishes them: it is recorded whenever a resume attempt fails and cleared once a resume succeeds, so a null value means the run is waiting on human input. When the failure is not retryable or the automatic retries are exhausted, the reason is prefixed `Automatic resume requires manual intervention: `. Run-list items carry no `paused` object, so the two cases are indistinguishable there."
|
||||
"description": "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run is waiting to be resumed — either held at a human-in-the-loop pause point, or left paused by a resume attempt that did not complete. Only the single-run response distinguishes the two, through `paused.automaticResumeWaitingReason`."
|
||||
},
|
||||
"trigger": {
|
||||
"type": "string",
|
||||
@@ -4205,7 +4231,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself."
|
||||
"description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself."
|
||||
}
|
||||
},
|
||||
"required": ["data", "nextCursor"],
|
||||
@@ -4259,7 +4285,7 @@
|
||||
"cancelled",
|
||||
"queued"
|
||||
],
|
||||
"description": "Current or terminal run status. `redacting` is transient, reported while the output of a finished run is being scrubbed. `paused` means the run is not executing and is waiting to be resumed: either held at a human-in-the-loop pause point, or left paused because a resume attempt did not run to completion. The status alone does not say which. On the single-run response `paused.automaticResumeWaitingReason` distinguishes them: it is recorded whenever a resume attempt fails and cleared once a resume succeeds, so a null value means the run is waiting on human input. When the failure is not retryable or the automatic retries are exhausted, the reason is prefixed `Automatic resume requires manual intervention: `. Run-list items carry no `paused` object, so the two cases are indistinguishable there."
|
||||
"description": "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run is waiting to be resumed — either held at a human-in-the-loop pause point, or left paused by a resume attempt that did not complete. Only the single-run response distinguishes the two, through `paused.automaticResumeWaitingReason`."
|
||||
},
|
||||
"trigger": {
|
||||
"anyOf": [
|
||||
@@ -4270,7 +4296,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Trigger type, or null before the run is recorded."
|
||||
"description": "Trigger type that started the run. Backfilled as `api` for a run that is still queued, so it is populated from the first poll."
|
||||
},
|
||||
"startedAt": {
|
||||
"anyOf": [
|
||||
@@ -4281,7 +4307,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "ISO 8601 start timestamp, or null while queued.",
|
||||
"description": "ISO 8601 start timestamp. A queued run reports the time it was enqueued, so it is populated from the first poll.",
|
||||
"format": "date-time"
|
||||
},
|
||||
"endedAt": {
|
||||
@@ -4374,7 +4400,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Reason automatic resume is waiting, or null when it is not waiting."
|
||||
"description": "Why automatic resume is waiting, or null when it is not — on a paused run, null means it is waiting on human input. Recorded whenever a resume attempt fails and cleared once one succeeds. A non-retryable or exhausted failure is prefixed `Automatic resume requires manual intervention: `."
|
||||
},
|
||||
"pausePointCount": {
|
||||
"type": "number",
|
||||
@@ -4431,7 +4457,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Structured execution failure, or null when none occurred."
|
||||
"description": "Structured execution failure, or null when none occurred. Reclassified from the persisted error message, so `blockId`/`blockName`/`blockType` are absent and a block-level failure reports `EXECUTION_FAILED` here even when the same run reported `BLOCK_EXECUTION_FAILED` on its synchronous execute response."
|
||||
},
|
||||
"output": {
|
||||
"anyOf": [
|
||||
@@ -4459,7 +4485,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Selected block outputs when requested, otherwise null."
|
||||
"description": "Outputs of the blocks named by `selectedOutputs`, or null when none were requested. Gated by `selectedOutputs` alone — `includeOutput` governs `output` only."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -4639,7 +4665,7 @@
|
||||
},
|
||||
"durablyRecorded": {
|
||||
"type": "boolean",
|
||||
"description": "Whether cancellation was recorded durably."
|
||||
"description": "Whether this request durably recorded a cancellation. Always false for a run that was already terminal, where the request is satisfied but nothing was written."
|
||||
},
|
||||
"locallyAborted": {
|
||||
"type": "boolean",
|
||||
@@ -4650,10 +4676,13 @@
|
||||
"description": "Whether a paused execution was cancelled."
|
||||
},
|
||||
"reason": {
|
||||
"description": "Machine-readable cancellation outcome. Present on every cancellation, including full successes — it is not a partial-failure marker. `recorded` means cancellation was durably recorded (the normal success value). `redis_unavailable` and `redis_write_failed` mean the distributed cancellation signal could not be written, so an already-running execution may not observe the cancellation. `paused_event_publish_failed` and `paused_database_cancel_failed` name the failing step when cancelling a paused human-in-the-loop run.",
|
||||
"description": "Machine-readable cancellation outcome, present on every cancellation including full successes. `recorded` is the success value. `already_cancelled`, `already_completed`, and `already_failed` mean the run had already reached that terminal state, so nothing was cancelled and `durablyRecorded` is false. `redis_unavailable` and `redis_write_failed` mean the distributed cancellation signal was not written, so an already-running execution may not observe the cancellation. `paused_event_publish_failed` and `paused_database_cancel_failed` name the failing step for a paused run.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"recorded",
|
||||
"already_cancelled",
|
||||
"already_completed",
|
||||
"already_failed",
|
||||
"redis_unavailable",
|
||||
"redis_write_failed",
|
||||
"paused_event_publish_failed",
|
||||
@@ -4671,7 +4700,7 @@
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"title": "Cancel workflow run result",
|
||||
"description": "Outcome of a workflow run cancellation request. Cancelling a run that has already reached a terminal state (completed, failed, or cancelled) succeeds with no effect rather than returning an error — treat this endpoint as best-effort and poll the run to observe the final state."
|
||||
"description": "Outcome of a workflow run cancellation request. Cancellation is best-effort: a run already in a terminal state succeeds with no effect, reported as `durablyRecorded: false` with an `already_*` reason naming the state observed."
|
||||
},
|
||||
"CancelWorkflowRunResponse": {
|
||||
"type": "object",
|
||||
@@ -4708,11 +4737,15 @@
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Canonical folder path used as the public folder identifier."
|
||||
"title": "Non-root folder path",
|
||||
"description": "Canonical folder path used as the public folder identifier.",
|
||||
"maxLength": 4096
|
||||
},
|
||||
"parentPath": {
|
||||
"type": "string",
|
||||
"description": "Canonical parent path; `/` is the root."
|
||||
"title": "Folder path",
|
||||
"description": "Canonical parent path; `/` is the root.",
|
||||
"maxLength": 4096
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
@@ -4753,7 +4786,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself."
|
||||
"description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change."
|
||||
}
|
||||
},
|
||||
"required": ["data", "nextCursor"],
|
||||
@@ -4801,17 +4834,24 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"NonRootFolderPathInput": {
|
||||
"title": "Non-root folder path input",
|
||||
"description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.",
|
||||
"maxLength": 4096,
|
||||
"type": "string"
|
||||
},
|
||||
"CreateWorkflowFolderRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workspaceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace in which to create the folder."
|
||||
},
|
||||
"path": {
|
||||
"description": "Path of the folder to create.",
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/NonRootFolderPathInput"
|
||||
}
|
||||
},
|
||||
"required": ["workspaceId", "path"],
|
||||
@@ -4850,15 +4890,16 @@
|
||||
"workspaceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Workspace containing the folder."
|
||||
},
|
||||
"path": {
|
||||
"description": "Current folder path.",
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/NonRootFolderPathInput"
|
||||
},
|
||||
"destinationPath": {
|
||||
"description": "New full path for the folder and its descendants.",
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/NonRootFolderPathInput"
|
||||
}
|
||||
},
|
||||
"required": ["workspaceId", "path", "destinationPath"],
|
||||
@@ -4871,7 +4912,9 @@
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path of the deleted workflow folder."
|
||||
"title": "Folder path",
|
||||
"description": "Path of the deleted workflow folder.",
|
||||
"maxLength": 4096
|
||||
},
|
||||
"deleted": {
|
||||
"type": "boolean",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
workflowEdges,
|
||||
workflowSubflows,
|
||||
} from '@sim/db'
|
||||
import { withUtcTimestamps } from '@sim/db/timestamps'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getActiveWorkflowContext } from '@sim/platform-authz/workflow'
|
||||
import {
|
||||
@@ -222,16 +223,20 @@ const connectionString =
|
||||
// Realtime process footprint = this socketDb pool + the shared @sim/db pool.
|
||||
const socketDb = drizzle(
|
||||
instrumentPoolClient(
|
||||
postgres(connectionString, {
|
||||
prepare: false,
|
||||
// See `packages/db/db.ts` — skips the per-connection pg_type roundtrip.
|
||||
fetch_types: false,
|
||||
idle_timeout: 10,
|
||||
connect_timeout: 20,
|
||||
max: 10,
|
||||
onnotice: () => {},
|
||||
connection: { application_name: process.env.DB_APP_NAME ?? 'sim-realtime' },
|
||||
}),
|
||||
postgres(
|
||||
connectionString,
|
||||
// `withUtcTimestamps` — see `packages/db/timestamps.ts`.
|
||||
withUtcTimestamps({
|
||||
prepare: false,
|
||||
// See `packages/db/db.ts` — skips the per-connection pg_type roundtrip.
|
||||
fetch_types: false,
|
||||
idle_timeout: 10,
|
||||
connect_timeout: 20,
|
||||
max: 10,
|
||||
onnotice: () => {},
|
||||
connection: { application_name: process.env.DB_APP_NAME ?? 'sim-realtime' },
|
||||
})
|
||||
),
|
||||
'socketDb'
|
||||
),
|
||||
{ schema }
|
||||
@@ -273,34 +278,6 @@ function findDbDescendants(containerId: string, allBlocks: DbBlockRef[]): string
|
||||
return descendants
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared function to handle auto-connect edge insertion
|
||||
* @param tx - Database transaction
|
||||
* @param workflowId - The workflow ID
|
||||
* @param autoConnectEdge - The auto-connect edge data
|
||||
* @param logger - Logger instance
|
||||
*/
|
||||
async function insertAutoConnectEdge(
|
||||
tx: any,
|
||||
workflowId: string,
|
||||
autoConnectEdge: any,
|
||||
logger: any
|
||||
) {
|
||||
if (!autoConnectEdge) return
|
||||
|
||||
await tx.insert(workflowEdges).values({
|
||||
id: autoConnectEdge.id,
|
||||
workflowId,
|
||||
sourceBlockId: autoConnectEdge.source,
|
||||
targetBlockId: autoConnectEdge.target,
|
||||
sourceHandle: normalizeWorkflowEdgeSourceHandle(autoConnectEdge.sourceHandle),
|
||||
targetHandle: normalizeWorkflowEdgeTargetHandle(autoConnectEdge.targetHandle),
|
||||
})
|
||||
logger.debug(
|
||||
`Added auto-connect edge ${autoConnectEdge.id}: ${autoConnectEdge.source} -> ${autoConnectEdge.target}`
|
||||
)
|
||||
}
|
||||
|
||||
enum SubflowType {
|
||||
LOOP = 'loop',
|
||||
PARALLEL = 'parallel',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
@@ -15,6 +16,16 @@ interface Backing {
|
||||
seq: number
|
||||
/** Number of upcoming xAdd calls to fail with a transient error (to exercise publish retry). */
|
||||
failXAdd: number
|
||||
/** Set to fail every xRead the way node-redis does once a client has been closed. */
|
||||
readerClosed: boolean
|
||||
/** Failed reads served, so a test can prove the loop is not spinning at the read cadence. */
|
||||
reads: number
|
||||
/** When each FAILED read was attempted, so a test can measure one backoff interval exactly. */
|
||||
failedReadTimes: number[]
|
||||
/** Reads that returned (the idle steady state) — the event that ends a failure streak. */
|
||||
idleReads: number
|
||||
/** `connect()` calls, so a test can prove a closed reader is re-opened rather than abandoned. */
|
||||
connects: number
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({ backing: null as Backing | null }))
|
||||
@@ -27,7 +38,11 @@ function makeClient(): any {
|
||||
return state.backing
|
||||
}
|
||||
const client: any = {
|
||||
connect: async () => {},
|
||||
isOpen: true,
|
||||
connect: async () => {
|
||||
client.isOpen = true
|
||||
b().connects++
|
||||
},
|
||||
quit: async () => {},
|
||||
on: () => client,
|
||||
duplicate: () => makeClient(),
|
||||
@@ -52,14 +67,24 @@ function makeClient(): any {
|
||||
)
|
||||
},
|
||||
xRead: async (streams: { key: string; id: string }[]) => {
|
||||
b().reads++
|
||||
if (b().readerClosed) {
|
||||
b().failedReadTimes.push(Date.now())
|
||||
client.isOpen = false
|
||||
throw new Error('The client is closed')
|
||||
}
|
||||
const res: { name: string; messages: { id: string; message: Record<string, string> }[] }[] =
|
||||
[]
|
||||
for (const { key, id } of streams) {
|
||||
const after = (b().streams.get(key) ?? []).filter((e) => seqOf(e.id) > seqOf(id))
|
||||
if (after.length) res.push({ name: key, messages: after.map((e) => ({ ...e })) })
|
||||
}
|
||||
if (res.length) return res
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
if (res.length) {
|
||||
b().idleReads++
|
||||
return res
|
||||
}
|
||||
await sleep(5)
|
||||
b().idleReads++
|
||||
return null
|
||||
},
|
||||
set: async (key: string, val: string, opts?: { NX?: boolean }) => {
|
||||
@@ -129,7 +154,17 @@ async function newStore(): Promise<FileDocStore> {
|
||||
|
||||
describe('FileDocStore', () => {
|
||||
beforeEach(() => {
|
||||
state.backing = { streams: new Map(), kv: new Map(), seq: 0, failXAdd: 0 }
|
||||
state.backing = {
|
||||
streams: new Map(),
|
||||
kv: new Map(),
|
||||
seq: 0,
|
||||
failXAdd: 0,
|
||||
readerClosed: false,
|
||||
reads: 0,
|
||||
failedReadTimes: [],
|
||||
idleReads: 0,
|
||||
connects: 0,
|
||||
}
|
||||
stores = []
|
||||
})
|
||||
|
||||
@@ -137,6 +172,85 @@ describe('FileDocStore', () => {
|
||||
await Promise.all(stores.map((s) => s.shutdown()))
|
||||
})
|
||||
|
||||
/**
|
||||
* A connection that stops serving reads used to spin the tailer at the read cadence — two attempts a
|
||||
* second, one warning each, forever — while the task quietly stopped converging with every other one.
|
||||
* The loop must back off instead, and re-open a client that was closed rather than reading a dead one.
|
||||
*/
|
||||
it('backs off and re-opens the reader when its connection is closed, instead of spinning', async () => {
|
||||
const store = await newStore()
|
||||
const doc = new Y.Doc()
|
||||
await store.attachRoom(NAME, doc)
|
||||
state.backing!.readerClosed = true
|
||||
|
||||
state.backing!.connects = 0 // ignore the two `init` connects; count only recovery attempts
|
||||
const before = state.backing!.reads
|
||||
await sleep(3000)
|
||||
const attempts = state.backing!.reads - before
|
||||
|
||||
// A fixed 500ms retry manages 6–7 attempts in this window; backing off (500 → 1s → 2s → …) manages
|
||||
// about 3. Exact counts are timing-dependent, so assert the property — it slowed down — not a number.
|
||||
expect(attempts).toBeGreaterThan(0)
|
||||
expect(attempts).toBeLessThanOrEqual(4)
|
||||
// …and it tried to bring the connection back rather than leaving the tailer dead forever.
|
||||
expect(state.backing!.connects).toBeGreaterThan(0)
|
||||
doc.destroy()
|
||||
})
|
||||
|
||||
/**
|
||||
* The streak has to end on a read that RETURNS, not on one that carries messages: a blocking read
|
||||
* timing out with nothing new is the idle steady state. Counting only message-bearing reads would
|
||||
* keep a healed outage's streak alive through normal polling, so the next unrelated blip would open
|
||||
* at the backoff cap — minutes of unnecessary split-brain — and log a count it never earned.
|
||||
*/
|
||||
it('ends the failure streak on an idle read, so a later blip starts over', async () => {
|
||||
const store = await newStore()
|
||||
const doc = new Y.Doc()
|
||||
await store.attachRoom(NAME, doc)
|
||||
|
||||
// Build a streak of two failures (the retries back off ~0.5s, then ~1s).
|
||||
state.backing!.readerClosed = true
|
||||
await vi.waitFor(
|
||||
() => expect(state.backing!.failedReadTimes.length).toBeGreaterThanOrEqual(2),
|
||||
{
|
||||
timeout: 5000,
|
||||
interval: 25,
|
||||
}
|
||||
)
|
||||
|
||||
// Redis comes back. Wait for a read to actually RETURN — waiting a fixed span instead is a race:
|
||||
// the pending backoff can outlast it, no idle read lands, and the streak survives into the phase
|
||||
// below, which then measures the wrong backoff and fails. That is an event, so wait on the event.
|
||||
state.backing!.readerClosed = false
|
||||
const idleBefore = state.backing!.idleReads
|
||||
await vi.waitFor(() => expect(state.backing!.idleReads).toBeGreaterThan(idleBefore), {
|
||||
timeout: 5000,
|
||||
interval: 25,
|
||||
})
|
||||
|
||||
// A fresh blip must retry at the START of the backoff curve, not partway up it. Assert the DELAY
|
||||
// itself: counting attempts inside a fixed window cannot tell the two apart, because the jittered
|
||||
// delay for a carried streak (1.6–2.4s) overlaps any window wide enough to catch a reset one.
|
||||
// Measure FAILURE to FAILURE so the sample is exactly one backoff — a straggler successful read
|
||||
// landing just after the flag flips would otherwise become the first sample and pass trivially.
|
||||
state.backing!.readerClosed = true
|
||||
state.backing!.failedReadTimes.length = 0
|
||||
await vi.waitFor(
|
||||
() => expect(state.backing!.failedReadTimes.length).toBeGreaterThanOrEqual(2),
|
||||
{
|
||||
timeout: 6000,
|
||||
interval: 25,
|
||||
}
|
||||
)
|
||||
const [first, second] = state.backing!.failedReadTimes
|
||||
|
||||
// Streak reset ⇒ the first delay is 500ms ±20% ⇒ at most 600ms. Streak carried over ⇒ it is the
|
||||
// third delay, 2000ms ±20% ⇒ at least 1600ms. The bound sits between them with room on both
|
||||
// sides, so a loaded machine stretching the short sleep does not flip the verdict.
|
||||
expect(second - first).toBeLessThan(1200)
|
||||
doc.destroy()
|
||||
})
|
||||
|
||||
it('elects exactly one seeder across tasks (no split-brain seed)', async () => {
|
||||
const a = await newStore()
|
||||
const b = await newStore()
|
||||
|
||||
@@ -18,10 +18,12 @@
|
||||
* client receives each update exactly once, from its own task's local broadcast — no adapter
|
||||
* amplification, and every task's doc stays converged. (Awareness/presence stay on the adapter: they
|
||||
* are ephemeral and need no convergence or replay.)
|
||||
* - {@link attachRoom} does a synchronous catch-up read from the head of the stream when a task first
|
||||
* opens a file, so a late-joining task (the normal case under autoscaling) loads the current shared
|
||||
* state before its first client syncs. Catch-up + tail are seamless: the tailer resumes from the
|
||||
* exact id catch-up stopped at.
|
||||
* - {@link attachRoom} reads the stream from the head when a task first opens a file, and the relay
|
||||
* AWAITS it before attaching a client, so a late-joining task (the normal case under autoscaling)
|
||||
* holds the current shared state before its first client syncs — a client must never watch the
|
||||
* catch-up land entry by entry, which is the document's edit history replaying on screen. Catch-up +
|
||||
* tail are seamless: the tailer resumes from the exact id catch-up stopped at, and {@link catchUp}
|
||||
* can re-run at any time for a caller that must converge without waiting on the tailer.
|
||||
* - The one-time seed is written via the atomic {@link seedIfEmpty} (append-iff-empty in one Redis
|
||||
* step), so exactly one task ever writes the seed cluster-wide (the fix for split-brain) — even if two
|
||||
* tasks race. {@link shouldSeed} is a Redis lock + empty-stream check layered on top ONLY as an
|
||||
@@ -158,6 +160,12 @@ const SEED_LOCK_TTL_MS = FILE_DOC_TIMEOUTS.seedRequestMs + 4_000
|
||||
const STREAM_TTL_SEC = 600
|
||||
/** Refresh every occupied stream's TTL on this cadence, so a live doc's stream never expires. */
|
||||
const HEARTBEAT_MS = 60_000
|
||||
/** Cap on the delay between reconnection attempts — the strategy retries indefinitely (see `init`). */
|
||||
const RECONNECT_MAX_DELAY_MS = 3_000
|
||||
/** Cap on the reader's own retry backoff after a failed read. */
|
||||
const READER_RETRY_MAX_MS = 10_000
|
||||
/** After the first failure of a streak, log one reader failure in this many. */
|
||||
const READER_ERROR_LOG_EVERY = 20
|
||||
|
||||
const streamKey = (name: string) => `${STREAM_PREFIX}${name}`
|
||||
|
||||
@@ -185,6 +193,18 @@ function applyEntryToDoc(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether stream id `id` sorts after `than`. A Redis stream id is `<ms>-<seq>`, so a lexicographic
|
||||
* compare is wrong the moment the millisecond part changes digit length (`'9999-0' > '10000-0'`);
|
||||
* compare the two parts numerically instead. The initial `'0'` (nothing applied) has no `-seq` part,
|
||||
* which reads as sequence 0 — before every real entry.
|
||||
*/
|
||||
function isAfterStreamId(id: string, than: string): boolean {
|
||||
const [ms, seq = '0'] = id.split('-')
|
||||
const [thanMs, thanSeq = '0'] = than.split('-')
|
||||
return Number(ms) === Number(thanMs) ? Number(seq) > Number(thanSeq) : Number(ms) > Number(thanMs)
|
||||
}
|
||||
|
||||
/** Whether a doc carries the seed flag (mirrors the relay's `isDocSeeded`), so the store can tell the
|
||||
* one-time seed transition from a real post-seed edit without re-implementing the check divergently. */
|
||||
function isDocSeeded(doc: Y.Doc): boolean {
|
||||
@@ -232,10 +252,17 @@ export class FileDocStore {
|
||||
const options = {
|
||||
url: this.redisUrl,
|
||||
socket: {
|
||||
reconnectStrategy: (retries: number) => {
|
||||
if (retries > 10) return new Error('FileDocStore Redis reconnection failed')
|
||||
return Math.min(retries * 100, 3000)
|
||||
},
|
||||
/**
|
||||
* Never stop reconnecting. Returning an `Error` here tells node-redis to give up and CLOSE the
|
||||
* client — and a closed client rejects every command with "The client is closed" for the rest of
|
||||
* the process's life. So an outage longer than the retry budget does not degrade this task, it
|
||||
* takes it out silently: its rooms stop receiving other tasks' updates, its own edits stop
|
||||
* reaching the shared stream, seeds and locks fail, and the only symptom is a warning per retry.
|
||||
* This process holds live documents whose sole convergence path is this connection, so a
|
||||
* connection it can rebuild is always worth rebuilding.
|
||||
*/
|
||||
reconnectStrategy: (retries: number) =>
|
||||
backoffWithJitter(retries + 1, null, { baseMs: 100, maxMs: RECONNECT_MAX_DELAY_MS }),
|
||||
},
|
||||
}
|
||||
this.write = createClient(options)
|
||||
@@ -260,10 +287,9 @@ export class FileDocStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a locally-opened room and load the shared state into its doc: read the whole stream from
|
||||
* the head, apply every entry (origin {@link REDIS_ORIGIN}), and remember the last id so the tailer
|
||||
* resumes exactly after it. A brand-new file has an empty stream and loads nothing (it is seeded
|
||||
* shortly after, via {@link shouldSeed}). No-op when disabled.
|
||||
* Register a locally-opened room and load the shared state into its doc ({@link catchUp}). A
|
||||
* brand-new file has an empty stream and loads nothing (it is seeded shortly after, via
|
||||
* {@link shouldSeed}). No-op when disabled.
|
||||
*/
|
||||
async attachRoom(name: string, doc: Y.Doc): Promise<void> {
|
||||
if (!this.enabled || !this.write) return
|
||||
@@ -277,12 +303,31 @@ export class FileDocStore {
|
||||
realEdited: false,
|
||||
}
|
||||
this.rooms.set(name, room)
|
||||
await this.catchUp(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* PULL the shared state into a registered room: read the stream and apply every entry the doc has
|
||||
* not integrated yet (origin {@link REDIS_ORIGIN}), advancing `lastId` so the tailer resumes exactly
|
||||
* after it. This is the ONLY way a room loads shared state, so a caller that must not depend on the
|
||||
* tailer's asynchronous push — the join, which may not serve a client a half-assembled document —
|
||||
* can converge on demand. Idempotent and safe to call repeatedly; no-op when disabled or the room is
|
||||
* not registered (a fast open→close detached it). Never throws.
|
||||
*/
|
||||
async catchUp(name: string): Promise<void> {
|
||||
if (!this.enabled || !this.write) return
|
||||
const room = this.rooms.get(name)
|
||||
if (!room) return
|
||||
try {
|
||||
const entries = await this.write.xRange(streamKey(name), '-', '+')
|
||||
for (const entry of entries) {
|
||||
// The room can be detached + its doc destroyed while catch-up is in flight (a fast open→close);
|
||||
// stop touching it the moment that happens.
|
||||
// The room can be detached + its doc destroyed while the read is in flight (a fast
|
||||
// open→close); stop touching it the moment that happens.
|
||||
if (this.rooms.get(name) !== room) return
|
||||
// Applying a Yjs update twice is a no-op, but `applyEntry`'s bookkeeping is not: re-applying
|
||||
// the SEED after `seededObserved` latched would count it as a post-seed edit and let a
|
||||
// compaction snapshot claim content no user ever typed. Skip what this room already holds.
|
||||
if (!isAfterStreamId(entry.id, room.lastId)) continue
|
||||
this.applyEntry(room, entry.id, entry.message)
|
||||
}
|
||||
await this.write.expire(streamKey(name), STREAM_TTL_SEC)
|
||||
@@ -619,6 +664,7 @@ export class FileDocStore {
|
||||
* apply new entries. One blocking connection for the whole process regardless of open-file count.
|
||||
*/
|
||||
private async runReader(): Promise<void> {
|
||||
let failures = 0
|
||||
while (this.running && this.read) {
|
||||
const snapshot = new Map(this.rooms)
|
||||
if (snapshot.size === 0) {
|
||||
@@ -630,6 +676,12 @@ export class FileDocStore {
|
||||
[...snapshot].map(([name, room]) => ({ key: streamKey(name), id: room.lastId })),
|
||||
{ BLOCK: READ_BLOCK_MS, COUNT: READ_COUNT }
|
||||
)
|
||||
// The streak ends HERE, on the read returning at all — not further down once entries are
|
||||
// applied. A blocking read that times out with nothing new is the idle steady state, and it
|
||||
// proves the connection works just as well as one carrying messages; leaving the streak
|
||||
// standing through it would keep an old outage's count alive indefinitely, so the next
|
||||
// unrelated blip would open at the backoff cap and log a failure count it never earned.
|
||||
failures = 0
|
||||
if (!res) continue
|
||||
for (const stream of res) {
|
||||
const name = stream.name.slice(STREAM_PREFIX.length)
|
||||
@@ -642,12 +694,40 @@ export class FileDocStore {
|
||||
}
|
||||
} catch (error) {
|
||||
if (!this.running) break
|
||||
logger.warn('FileDocStore reader error; retrying', { error: getErrorMessage(error) })
|
||||
await sleep(500)
|
||||
await this.recoverReader(++failures, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed read is either a transient blip or a connection that is gone, and this loop cannot tell
|
||||
* them apart — so it backs off instead of retrying at the read cadence. Without that, a connection
|
||||
* that cannot serve reads spins this loop forever at two attempts a second, one warning each, which
|
||||
* is how an outage turns into thousands of identical log lines that bury the reason for it.
|
||||
*
|
||||
* It also re-opens a CLOSED client. node-redis reconnects a client that merely dropped, but never one
|
||||
* it has closed; the strategy above no longer closes one, so this covers a client closed some other
|
||||
* way (an explicit disconnect, a shutdown that raced a read) rather than leaving the tailer dead.
|
||||
*
|
||||
* Logs the first failure of a streak and then one in every {@link READER_ERROR_LOG_EVERY}, carrying
|
||||
* the streak length, so a real outage stays visible without filling the log.
|
||||
*/
|
||||
private async recoverReader(failures: number, error: unknown): Promise<void> {
|
||||
if (failures === 1 || failures % READER_ERROR_LOG_EVERY === 0) {
|
||||
logger.warn(`FileDocStore reader failed ${failures}x in a row; retrying`, {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
await sleep(backoffWithJitter(failures, null, { baseMs: 500, maxMs: READER_RETRY_MAX_MS }))
|
||||
if (this.running && this.read && !this.read.isOpen) {
|
||||
await this.read.connect().catch((reconnectError) => {
|
||||
logger.warn('FileDocStore could not re-open the reader connection', {
|
||||
error: getErrorMessage(reconnectError),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot-then-trim compaction: append a full-state snapshot and drop the older deltas it subsumes,
|
||||
* so the stream stays bounded while a fresh task can still catch up from the head. Lock-guarded so
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*
|
||||
* The join's readiness contract, with the shared store ENABLED (`file-doc.test.ts` runs it disabled).
|
||||
*
|
||||
* A room loads its document from the file's Redis stream one entry at a time, into the same `Y.Doc`
|
||||
* that fans every update out to the room. So a client attached while that is happening is not sent the
|
||||
* document — it is sent the document's history, and it watches the history replay on screen (reload
|
||||
* right after moving a block and the block moves again in front of you). These tests pin the fix: the
|
||||
* join waits for the room to hold the whole document, so the client's first sync is authoritative.
|
||||
*/
|
||||
import {
|
||||
FILE_DOC_EVENTS,
|
||||
FILE_DOC_MESSAGE_TYPE,
|
||||
FILE_DOC_SEED,
|
||||
} from '@sim/realtime-protocol/file-doc'
|
||||
import * as decoding from 'lib0/decoding'
|
||||
import * as encoding from 'lib0/encoding'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as syncProtocol from 'y-protocols/sync'
|
||||
import * as Y from 'yjs'
|
||||
import type { IRoomManager } from '@/rooms'
|
||||
|
||||
const { mockAuthorizeRoom, mockFetchFileDocSeed } = vi.hoisted(() => ({
|
||||
mockAuthorizeRoom: vi.fn(),
|
||||
mockFetchFileDocSeed: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/platform-authz/rooms', () => ({ authorizeRoom: mockAuthorizeRoom }))
|
||||
|
||||
vi.mock('@/handlers/file-doc-app', () => ({
|
||||
fetchFileDocSeed: mockFetchFileDocSeed,
|
||||
fetchFileDocMerge: vi.fn(),
|
||||
fetchFileDocPersist: vi.fn().mockResolvedValue({ status: 'persisted', version: 1 }),
|
||||
}))
|
||||
|
||||
/** One in-memory Redis backing per test — only the stream/lock ops the store actually uses. */
|
||||
const backing = vi.hoisted(() => ({
|
||||
streams: new Map<string, { id: string; message: Record<string, string> }[]>(),
|
||||
kv: new Map<string, string>(),
|
||||
seq: 0,
|
||||
/** Ticks of event-loop delay each xRange takes, modelling a remote (cross-region) Redis. */
|
||||
readDelayTicks: 0,
|
||||
}))
|
||||
|
||||
const seqOf = (id: string) => Number(id.split('-')[0])
|
||||
|
||||
vi.mock('redis', () => {
|
||||
const makeClient = (): Record<string, unknown> => {
|
||||
const client: Record<string, unknown> = {
|
||||
connect: async () => {},
|
||||
quit: async () => {},
|
||||
on: () => client,
|
||||
duplicate: () => makeClient(),
|
||||
xAdd: async (key: string, _star: string, fields: Record<string, string>) => {
|
||||
const id = `${++backing.seq}-0`
|
||||
const arr = backing.streams.get(key) ?? []
|
||||
arr.push({ id, message: { ...fields } })
|
||||
backing.streams.set(key, arr)
|
||||
return id
|
||||
},
|
||||
xRange: async (key: string) => {
|
||||
for (let i = 0; i < backing.readDelayTicks; i++) await Promise.resolve()
|
||||
return (backing.streams.get(key) ?? []).map((e) => ({ ...e }))
|
||||
},
|
||||
xLen: async (key: string) => (backing.streams.get(key) ?? []).length,
|
||||
xRead: async (streams: { key: string; id: string }[]) => {
|
||||
const res: { name: string; messages: { id: string; message: Record<string, string> }[] }[] =
|
||||
[]
|
||||
for (const { key, id } of streams) {
|
||||
const after = (backing.streams.get(key) ?? []).filter((e) => seqOf(e.id) > seqOf(id))
|
||||
if (after.length) res.push({ name: key, messages: after.map((e) => ({ ...e })) })
|
||||
}
|
||||
if (res.length) return res
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
return null
|
||||
},
|
||||
set: async (key: string, val: string, opts?: { NX?: boolean }) => {
|
||||
if (opts?.NX && backing.kv.has(key)) return null
|
||||
backing.kv.set(key, val)
|
||||
return 'OK'
|
||||
},
|
||||
eval: async (script: string, opts: { keys: string[]; arguments: string[] }) => {
|
||||
const [key] = opts.keys
|
||||
if (script.includes('xlen')) {
|
||||
const [field, value] = opts.arguments
|
||||
const arr = backing.streams.get(key) ?? []
|
||||
if (arr.length > 0) return 0
|
||||
arr.push({ id: `${++backing.seq}-0`, message: { [field]: value } })
|
||||
backing.streams.set(key, arr)
|
||||
return 1
|
||||
}
|
||||
const [token] = opts.arguments
|
||||
if (backing.kv.get(key) === token) {
|
||||
backing.kv.delete(key)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
},
|
||||
expire: async () => 1,
|
||||
get: async (key: string) => backing.kv.get(key) ?? null,
|
||||
exists: async (key: string) => (backing.kv.has(key) ? 1 : 0),
|
||||
}
|
||||
return client
|
||||
}
|
||||
return { createClient: () => makeClient() }
|
||||
})
|
||||
|
||||
import { cleanupFileDocForSocket, setupWorkspaceFileDocHandlers } from '@/handlers/file-doc'
|
||||
import { getFileDocStore, initFileDocStore } from '@/handlers/file-doc-store'
|
||||
|
||||
const FILE_ID = 'file-1'
|
||||
const ROOM_NAME = `workspace-file-doc:${FILE_ID}`
|
||||
const STREAM_KEY = `filedoc:stream:${ROOM_NAME}`
|
||||
const FIELD = 'default'
|
||||
|
||||
type Handler = (payload?: unknown) => Promise<void> | void
|
||||
|
||||
interface FakeSocket {
|
||||
id: string
|
||||
emit: (event: string, payload: unknown) => void
|
||||
rooms: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* An `io` that actually DELIVERS: a room emit reaches every socket that joined that room, so a frame
|
||||
* the relay fans out mid-assembly lands on the joiner's `emit` exactly as it would in the browser.
|
||||
* Recording the emits without routing them would hide the very thing these tests are about.
|
||||
*/
|
||||
function createIo(sockets: FakeSocket[]) {
|
||||
const emitTo = (target: string, except: string | null, event: string, payload: unknown) => {
|
||||
for (const socket of sockets) {
|
||||
if (socket.id === except || !socket.rooms.has(target)) continue
|
||||
socket.emit(event, payload)
|
||||
}
|
||||
}
|
||||
const to = vi.fn((target: string) => ({
|
||||
except: (exclude: string) => ({
|
||||
emit: (event: string, payload: unknown) => emitTo(target, exclude, event, payload),
|
||||
}),
|
||||
emit: (event: string, payload: unknown) => emitTo(target, null, event, payload),
|
||||
}))
|
||||
return {
|
||||
to,
|
||||
in: vi.fn(() => ({ socketsLeave: () => {} })),
|
||||
local: { to },
|
||||
} as unknown as IRoomManager['io']
|
||||
}
|
||||
|
||||
function setup(id: string, sockets: FakeSocket[]) {
|
||||
const handlers: Record<string, Handler> = {}
|
||||
const rooms = new Set<string>()
|
||||
const socket = {
|
||||
id,
|
||||
userId: 'user-1',
|
||||
userName: 'Test User',
|
||||
userImage: 'avatar.png',
|
||||
disconnected: false,
|
||||
rooms,
|
||||
on: vi.fn((event: string, handler: Handler) => {
|
||||
handlers[event] = handler
|
||||
}),
|
||||
emit: vi.fn(),
|
||||
join: vi.fn((name: string) => rooms.add(name)),
|
||||
leave: vi.fn((name: string) => rooms.delete(name)),
|
||||
}
|
||||
sockets.push(socket as unknown as FakeSocket)
|
||||
setupWorkspaceFileDocHandlers(
|
||||
socket as unknown as Parameters<typeof setupWorkspaceFileDocHandlers>[0],
|
||||
{ isReady: () => true, io: createIo(sockets) } as unknown as IRoomManager
|
||||
)
|
||||
return { socket, handlers }
|
||||
}
|
||||
|
||||
/** Append a Yjs update to the file's stream, exactly as `publish`/`seedIfEmpty` would. */
|
||||
function appendToStream(update: Uint8Array): void {
|
||||
const arr = backing.streams.get(STREAM_KEY) ?? []
|
||||
arr.push({ id: `${++backing.seq}-0`, message: { u: Buffer.from(update).toString('base64') } })
|
||||
backing.streams.set(STREAM_KEY, arr)
|
||||
}
|
||||
|
||||
/**
|
||||
* A warm room's history: the seed, then a later edit — the "I moved a block, then reloaded" case.
|
||||
* Returns the markdown-equivalent text of each state.
|
||||
*/
|
||||
function seedWarmStreamHistory(): { intermediate: string; final: string } {
|
||||
const doc = new Y.Doc()
|
||||
doc.getText(FIELD).insert(0, 'AAA')
|
||||
doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true)
|
||||
appendToStream(Y.encodeStateAsUpdate(doc))
|
||||
const afterSeed = Y.encodeStateVector(doc)
|
||||
doc.getText(FIELD).insert(0, 'BBB')
|
||||
appendToStream(Y.encodeStateAsUpdate(doc, afterSeed))
|
||||
doc.destroy()
|
||||
return { intermediate: 'AAA', final: 'BBBAAA' }
|
||||
}
|
||||
|
||||
/** Every document state this socket was ever shown, in order. */
|
||||
function statesDeliveredTo(socket: { emit: ReturnType<typeof vi.fn> }): string[] {
|
||||
const clientDoc = new Y.Doc()
|
||||
const states: string[] = []
|
||||
for (const [event, payload] of socket.emit.mock.calls) {
|
||||
if (event !== FILE_DOC_EVENTS.MESSAGE || !(payload instanceof Uint8Array)) continue
|
||||
const decoder = decoding.createDecoder(payload)
|
||||
if (decoding.readVarUint(decoder) !== FILE_DOC_MESSAGE_TYPE.SYNC) continue
|
||||
syncProtocol.readSyncMessage(decoder, encoding.createEncoder(), clientDoc, null)
|
||||
const text = clientDoc.getText(FIELD).toString()
|
||||
if (text !== (states.at(-1) ?? '')) states.push(text)
|
||||
}
|
||||
clientDoc.destroy()
|
||||
return states
|
||||
}
|
||||
|
||||
/** Let anything the join left running (a catch-up, a seed) settle, so a frame it fans out afterwards
|
||||
* is counted — that late delivery IS the replay these tests exist to rule out. */
|
||||
async function flushPendingWork(): Promise<void> {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve()
|
||||
}
|
||||
|
||||
/** Ask the server for its state the way a client does after the join ack. */
|
||||
function requestSyncStep2(handlers: Record<string, Handler>): void {
|
||||
const encoder = encoding.createEncoder()
|
||||
encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC)
|
||||
syncProtocol.writeSyncStep1(encoder, new Y.Doc())
|
||||
handlers[FILE_DOC_EVENTS.MESSAGE](encoding.toUint8Array(encoder))
|
||||
}
|
||||
|
||||
describe('file-doc join readiness (shared store enabled)', () => {
|
||||
/** Every socket the test created, so a room emit can be routed to its members. */
|
||||
const sockets: FakeSocket[] = []
|
||||
|
||||
// One store for the whole file: `initFileDocStore` is idempotent once enabled, so a per-test
|
||||
// shutdown would leave every later test running against a store with closed clients.
|
||||
beforeAll(async () => {
|
||||
await initFileDocStore('redis://fake')
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await getFileDocStore().shutdown()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
backing.streams.clear()
|
||||
backing.kv.clear()
|
||||
backing.seq = 0
|
||||
backing.readDelayTicks = 0
|
||||
mockAuthorizeRoom.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workspaceId: 'ws-1',
|
||||
workspacePermission: 'write',
|
||||
})
|
||||
mockFetchFileDocSeed.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanupFileDocForSocket('socket-1', createIo(sockets), true)
|
||||
sockets.length = 0
|
||||
})
|
||||
|
||||
it('hands a joiner the final document, never the room history it was rebuilt from', async () => {
|
||||
const { intermediate, final } = seedWarmStreamHistory()
|
||||
// The catch-up read is not instantaneous — the case that made this visible is a cross-region Redis.
|
||||
backing.readDelayTicks = 6
|
||||
const { socket, handlers } = setup('socket-1', sockets)
|
||||
|
||||
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: FILE_ID, clientId: 1 })
|
||||
requestSyncStep2(handlers)
|
||||
await flushPendingWork()
|
||||
|
||||
// One state, and it is the final one: the client never saw the pre-move document.
|
||||
expect(statesDeliveredTo(socket)).toEqual([final])
|
||||
expect(statesDeliveredTo(socket)).not.toContain(intermediate)
|
||||
})
|
||||
|
||||
it('does not fetch a seed for a room the stream can already reconstruct', async () => {
|
||||
seedWarmStreamHistory()
|
||||
const { handlers } = setup('socket-1', sockets)
|
||||
|
||||
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: FILE_ID, clientId: 1 })
|
||||
|
||||
expect(mockFetchFileDocSeed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('pulls a seed another writer put in the stream instead of waiting for the tailer to push it', async () => {
|
||||
// The seed lock is held by a writer whose room has since been dropped (a fast open→close on a
|
||||
// freshly created file), and its seed lands in the stream. Waiting to be told about it is what
|
||||
// left a new file un-editable until the client's readiness deadline lapsed; the join reads it.
|
||||
backing.kv.set(`filedoc:seedlock:${ROOM_NAME}`, 'held-by-a-writer-that-is-gone')
|
||||
const doc = new Y.Doc()
|
||||
doc.getText(FIELD).insert(0, 'seeded by the writer that held the lock')
|
||||
doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true)
|
||||
appendToStream(Y.encodeStateAsUpdate(doc))
|
||||
doc.destroy()
|
||||
|
||||
const { socket, handlers } = setup('socket-1', sockets)
|
||||
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: FILE_ID, clientId: 1 })
|
||||
requestSyncStep2(handlers)
|
||||
await flushPendingWork()
|
||||
|
||||
expect(mockFetchFileDocSeed).not.toHaveBeenCalled()
|
||||
expect(statesDeliveredTo(socket)).toEqual(['seeded by the writer that held the lock'])
|
||||
})
|
||||
})
|
||||
@@ -570,22 +570,42 @@ describe('setupWorkspaceFileDocHandlers', () => {
|
||||
expect(clientDoc.getText(FILE_DOC_FIELD).toString()).toBe('# From server')
|
||||
})
|
||||
|
||||
it('seeds the document only once from the server across concurrent joiners of the same file', async () => {
|
||||
it('seeds once across concurrent joiners, and every one of them waits for that seed', async () => {
|
||||
// Keep the first seed fetch IN FLIGHT so the doc is still unseeded when the second socket joins:
|
||||
// that forces the dedup onto `serverSeedStarted` (the in-flight guard) rather than `isDocSeeded`.
|
||||
// that forces the dedup onto the in-flight seed rather than `isDocSeeded`. Both joins must WAIT
|
||||
// for it — a joiner answered before the seed would be handed an empty document and would then
|
||||
// watch the content arrive as a live update.
|
||||
let resolveSeed: (v: { update: Uint8Array; version: number } | null) => void = () => {}
|
||||
mockFetchFileDocSeed.mockReturnValueOnce(new Promise((resolve) => (resolveSeed = resolve)))
|
||||
const { io } = createIo()
|
||||
const a = setup('socket-a', io)
|
||||
const b = setup('socket-b', io)
|
||||
|
||||
await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
|
||||
await b.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 2 })
|
||||
// Second join happened with the fetch still pending; only after this does the seed land.
|
||||
expect(mockFetchFileDocSeed).toHaveBeenCalledTimes(1)
|
||||
resolveSeed(seedResult('# From server'))
|
||||
const joinA = a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
|
||||
const joinB = b.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 2 })
|
||||
await flushMicrotasks()
|
||||
|
||||
// The second join found the seed already in flight, so it does not start another one — and
|
||||
// neither join has been answered yet.
|
||||
expect(mockFetchFileDocSeed).toHaveBeenCalledTimes(1)
|
||||
expect(joinSuccessFileId(a.socket)).toBeUndefined()
|
||||
expect(joinSuccessFileId(b.socket)).toBeUndefined()
|
||||
|
||||
resolveSeed(seedResult('# From server'))
|
||||
await Promise.all([joinA, joinB])
|
||||
expect(mockFetchFileDocSeed).toHaveBeenCalledTimes(1)
|
||||
|
||||
// The joiner that never triggered the fetch is served the seeded document all the same.
|
||||
b.socket.emit.mockClear()
|
||||
b.handlers[FILE_DOC_EVENTS.MESSAGE](
|
||||
frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => syncProtocol.writeSyncStep1(e, new Y.Doc()))
|
||||
)
|
||||
const reply = b.socket.emit.mock.calls.find(
|
||||
([event, payload]) => event === FILE_DOC_EVENTS.MESSAGE && payload instanceof Uint8Array
|
||||
)
|
||||
const clientDoc = new Y.Doc()
|
||||
applySyncReply(reply?.[1] as Uint8Array, clientDoc)
|
||||
expect(clientDoc.getText(FILE_DOC_FIELD).toString()).toBe('# From server')
|
||||
})
|
||||
|
||||
it('marks an empty/absent-file doc seeded so clients still reach readiness', async () => {
|
||||
@@ -640,43 +660,58 @@ describe('setupWorkspaceFileDocHandlers', () => {
|
||||
expect(clientDoc.getText(FILE_DOC_FIELD).toString()).toBe('# Recovered')
|
||||
})
|
||||
|
||||
it('does not seed a room that was dropped while the seed fetch was in flight', async () => {
|
||||
let resolveSeed: (v: { update: Uint8Array; version: number } | null) => void = () => {}
|
||||
mockFetchFileDocSeed.mockReturnValueOnce(new Promise((resolve) => (resolveSeed = resolve)))
|
||||
const { io } = createIo()
|
||||
const { handlers } = setup('socket-1', io)
|
||||
|
||||
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
|
||||
// The only owner leaves → the room (and its doc) is destroyed while the fetch is still pending.
|
||||
cleanupFileDocForSocket('socket-1', io, true)
|
||||
// Resolving now must not touch the destroyed doc or throw (liveness re-check after the await).
|
||||
resolveSeed(seedResult('# Too late'))
|
||||
await expect(flushMicrotasks()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('still seeds when content was synced into the doc before the seed returned', async () => {
|
||||
// Defensive: the guard is `isDocSeeded`, NOT doc-emptiness. In practice a fresh client never
|
||||
// writes ahead of the seed (@tiptap/y-tiptap suppresses the empty-paragraph placeholder and real
|
||||
// edits are readiness-gated), but even if some update landed content in the doc before the seed
|
||||
// fetch resolved, the seed must still apply and set the flag — or the client's
|
||||
// `synced && initialContentLoaded` gate would never open.
|
||||
it('does not seed a room the joiner abandoned while the seed fetch was in flight', async () => {
|
||||
let resolveSeed: (v: { update: Uint8Array; version: number } | null) => void = () => {}
|
||||
mockFetchFileDocSeed.mockReturnValueOnce(new Promise((resolve) => (resolveSeed = resolve)))
|
||||
const { io } = createIo()
|
||||
const { socket, handlers } = setup('socket-1', io)
|
||||
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
|
||||
|
||||
// The client syncs a placeholder update — content in the doc, but no seed flag.
|
||||
const placeholder = new Y.Doc()
|
||||
placeholder.getText(FILE_DOC_FIELD).insert(0, 'x')
|
||||
handlers[FILE_DOC_EVENTS.MESSAGE](
|
||||
frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) =>
|
||||
syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(placeholder))
|
||||
)
|
||||
)
|
||||
resolveSeed(seedResult('# Seeded'))
|
||||
const joining = handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
|
||||
await flushMicrotasks()
|
||||
// The client leaves before the room finished assembling → the join aborts and drops the room it
|
||||
// was preparing (nothing else owns it).
|
||||
handlers[FILE_DOC_EVENTS.LEAVE]({ fileId: 'file-1' })
|
||||
// Resolving now must not touch the destroyed doc or throw (liveness re-check after the await).
|
||||
resolveSeed(seedResult('# Too late'))
|
||||
await expect(joining).resolves.toBeUndefined()
|
||||
expect(joinSuccessFileId(socket)).toBeUndefined()
|
||||
expect(socket.join).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('attaches a client only once the document is whole — no empty sync, no frames before it', async () => {
|
||||
// The room assembles itself into the same doc that fans updates out to its room, so a socket
|
||||
// attached mid-assembly receives the document's history rather than the document. Nothing about
|
||||
// the client exists in the room until the seed has landed: no membership, no sync, and any frame
|
||||
// it sends meanwhile is not applied.
|
||||
let resolveSeed: (v: { update: Uint8Array; version: number } | null) => void = () => {}
|
||||
mockFetchFileDocSeed.mockReturnValueOnce(new Promise((resolve) => (resolveSeed = resolve)))
|
||||
const { io } = createIo()
|
||||
const { socket, handlers } = setup('socket-1', io)
|
||||
const joining = handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(socket.join).not.toHaveBeenCalled()
|
||||
expect(joinSuccessFileId(socket)).toBeUndefined()
|
||||
expect(
|
||||
socket.emit.mock.calls.some(
|
||||
([event, payload]) => event === FILE_DOC_EVENTS.MESSAGE && payload instanceof Uint8Array
|
||||
)
|
||||
).toBe(false)
|
||||
|
||||
// A document frame sent before the join was answered reaches an unbound socket and is dropped.
|
||||
const early = new Y.Doc()
|
||||
early.getText(FILE_DOC_FIELD).insert(0, 'too early')
|
||||
handlers[FILE_DOC_EVENTS.MESSAGE](
|
||||
frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) =>
|
||||
syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(early))
|
||||
)
|
||||
)
|
||||
|
||||
resolveSeed(seedResult('# Seeded'))
|
||||
await joining
|
||||
expect(joinSuccessFileId(socket)).toBe('file-1')
|
||||
|
||||
// The first thing the client is served is the finished document — content and seed flag together.
|
||||
socket.emit.mockClear()
|
||||
handlers[FILE_DOC_EVENTS.MESSAGE](
|
||||
frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => syncProtocol.writeSyncStep1(e, new Y.Doc()))
|
||||
@@ -687,7 +722,7 @@ describe('setupWorkspaceFileDocHandlers', () => {
|
||||
const clientDoc = new Y.Doc()
|
||||
applySyncReply(reply?.[1] as Uint8Array, clientDoc)
|
||||
expect(clientDoc.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.flag)).toBe(true)
|
||||
expect(clientDoc.getText(FILE_DOC_FIELD).toString()).toContain('# Seeded')
|
||||
expect(clientDoc.getText(FILE_DOC_FIELD).toString()).toBe('# Seeded')
|
||||
})
|
||||
|
||||
it('merges a copilot edit into a seeded live room and relays it to editors', async () => {
|
||||
|
||||
@@ -131,9 +131,13 @@ interface FileDocRoom {
|
||||
/** socketId → (clientId → its presence ownership). A socket owns one entry per collaborative provider
|
||||
* it mounted for this file (see {@link FileDocOwner}); an empty inner map is never kept. */
|
||||
owners: Map<string, Map<number, FileDocOwner>>
|
||||
/** True once the server-side seed fetch has started, so concurrent joins don't each fetch.
|
||||
* Reset on a fetch FAILURE so a later join can retry (a genuinely empty file stays empty). */
|
||||
serverSeedStarted: boolean
|
||||
/**
|
||||
* The in-flight server seed for this room, or `null`. Concurrent joins await THIS promise rather
|
||||
* than each starting a fetch — and, unlike a "started" boolean, awaiting it is what lets a second
|
||||
* joiner be served a document that is already seeded instead of an empty one. Cleared when it
|
||||
* settles, so a failed seed is re-attempted by a later join (a genuinely empty file stays empty).
|
||||
*/
|
||||
seeding: Promise<void> | null
|
||||
/** The workspace this file belongs to, captured at join — needed to persist back to markdown. */
|
||||
workspaceId: string | null
|
||||
/** The last collaborator to edit here, for persist attribution (blob metadata) only. */
|
||||
@@ -170,6 +174,17 @@ interface FileDocRoom {
|
||||
* {@link FileDocStore.isAgentStreaming} flag. `0` when no agent stream is active.
|
||||
*/
|
||||
agentStreamingUntil: number
|
||||
/**
|
||||
* Resolves once this room's doc reflects the file's shared stream (see {@link FileDocStore.catchUp}).
|
||||
* Never rejects — the catch-up logs and gives up — so awaiting it can never fail a join.
|
||||
*/
|
||||
hydrated: Promise<void>
|
||||
/**
|
||||
* How many joins are currently preparing this room. A room is created by the first join and has no
|
||||
* owner until that join commits, so without this a concurrent last-leave would tear down the very
|
||||
* document being assembled. A room with a join in flight is not idle.
|
||||
*/
|
||||
pendingJoins: number
|
||||
}
|
||||
|
||||
/** Live documents keyed by Socket.IO room name. Module-global: one Y.Doc per file. */
|
||||
@@ -360,7 +375,12 @@ async function flushPersist(name: string, room: FileDocRoom, final: boolean): Pr
|
||||
}
|
||||
if (result.status === 'persisted') {
|
||||
room.syncedVersion = Math.max(room.syncedVersion ?? 0, result.version)
|
||||
void store.setSyncedVersion(name, result.version)
|
||||
// AWAITED, unlike every other version write: the room's own copy dies with the room, so this
|
||||
// cluster key is the only record that survives a teardown or a process restart. Fire-and-forget
|
||||
// here means a task that exits in the moments after a write comes back holding a version older
|
||||
// than the file's, and — since a conflict neither writes nor advances the token — never persists
|
||||
// that document again. One round trip after a blob write is not a cost worth that.
|
||||
await store.setSyncedVersion(name, result.version)
|
||||
return
|
||||
}
|
||||
// status === 'conflict': the durable file advanced out-of-band since our If-Match token. We do NOT
|
||||
@@ -412,6 +432,12 @@ function isDocSeeded(doc: Y.Doc): boolean {
|
||||
return doc.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.flag) === true
|
||||
}
|
||||
|
||||
/** The identity of the document this doc holds ({@link FILE_DOC_SEED.docIdKey}), if it carries one. */
|
||||
function docIdOf(doc: Y.Doc): string | undefined {
|
||||
const docId = doc.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.docIdKey)
|
||||
return typeof docId === 'string' ? docId : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the client IDs an awareness update carries, without applying it, to
|
||||
* check a frame only touches its sender's own presence. Mirrors the wire format
|
||||
@@ -435,10 +461,13 @@ function awarenessUpdateClientIds(update: Uint8Array): number[] {
|
||||
* memory. Before dropping, flush the converged doc back to durable markdown (the last collaborator on
|
||||
* this task leaving) and detach from the shared stream. A later joiner re-creates it — catching up
|
||||
* from the stream if the doc is still live on another task, or re-seeding from markdown otherwise.
|
||||
*
|
||||
* A room being PREPARED for a join is not idle even though it has no owners yet: tearing it down there
|
||||
* would drop the hydration/seed that join is waiting on, and the join would have to start over.
|
||||
*/
|
||||
function destroyRoomIfIdle(name: string) {
|
||||
const room = fileDocRooms.get(name)
|
||||
if (!room || room.owners.size > 0) return
|
||||
if (!room || room.owners.size > 0 || room.pendingJoins > 0) return
|
||||
room.persistDeadline = null
|
||||
if (room.persistTimer) {
|
||||
clearTimeout(room.persistTimer)
|
||||
@@ -469,40 +498,83 @@ export async function flushAllFileDocRooms(): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a room's document server-side, once, on the first join: ask the app to build the seed (the
|
||||
* file's current markdown → Yjs, through the exact editor engine) and apply it, which relays the
|
||||
* content to every connected client via `doc.on('update')`. No client is elected to import content.
|
||||
* Bring a room's document to its AUTHORITATIVE state — reflecting the file's shared stream and
|
||||
* carrying its seed — so the join can attach a client to a document that is already whole. Never
|
||||
* rejects: a room that cannot be seeded is served unseeded, which the client's readiness deadline
|
||||
* turns into its read-only fallback, exactly as an unreachable relay does.
|
||||
*/
|
||||
async function ensureRoomReady(
|
||||
name: string,
|
||||
room: FileDocRoom,
|
||||
workspaceId: string | null
|
||||
): Promise<void> {
|
||||
await room.hydrated
|
||||
// The room can be dropped and re-created while the catch-up is in flight (a fast open→close); the
|
||||
// join re-checks identity after this and abandons a stale room rather than serving from it.
|
||||
if (fileDocRooms.get(name) !== room || !workspaceId) return
|
||||
await ensureServerSeed(name, room, workspaceId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a room's document server-side, once: ask the app to build the seed (the file's current markdown
|
||||
* → Yjs, through the exact editor engine) and apply it. No client is elected to import content.
|
||||
*
|
||||
* MEMOIZED on the room, so concurrent joins await the same seed instead of the second one being served
|
||||
* an empty document while the first one's fetch is still in flight. Cleared when it settles: a failed
|
||||
* seed is re-attempted by the next join (a genuinely empty file stays empty and needs no retry).
|
||||
*
|
||||
* `isDocSeeded` is the sufficient guard: content only ever reaches the doc alongside the seed flag
|
||||
* (this seed, or a client's offline fallback), so an unseeded doc is genuinely empty and safe to seed.
|
||||
* A genuinely empty/missing file returns `null` (a read error throws instead), so still set the flag —
|
||||
* an empty doc must reach readiness, not wait forever. After the fetch, re-check the room is still
|
||||
* live and unseeded (an owner may have left, or a client seeded it, while the fetch was in flight).
|
||||
*
|
||||
* Recovery on failure is deliberately simple — no in-room retry loop: a single attempt bounded by a
|
||||
* timeout shorter than the client's readiness deadline, then release the guard. A transient failure
|
||||
* is re-attempted by the next join/reconnect; a persistent one lets the connected client's readiness
|
||||
* deadline lapse into its read-only fallback. (An in-room backoff retry can outlast that client
|
||||
* deadline, so it would keep trying a doc the client has already given up on — worse, not better.)
|
||||
* an empty doc must reach readiness, not wait forever.
|
||||
*/
|
||||
async function ensureServerSeed(
|
||||
function ensureServerSeed(name: string, room: FileDocRoom, workspaceId: string): Promise<void> {
|
||||
if (isDocSeeded(room.doc)) return Promise.resolve()
|
||||
room.seeding ??= runServerSeed(name, room, workspaceId).finally(() => {
|
||||
room.seeding = null
|
||||
})
|
||||
return room.seeding
|
||||
}
|
||||
|
||||
/**
|
||||
* Whichever task wins the seed lock writes the seed; the others must end up holding the SAME seed
|
||||
* before they serve anyone. They pull it, on this cadence, rather than waiting for the tailer to push
|
||||
* it: a join's readiness may not depend on an asynchronous subscriber, because when that delivery is
|
||||
* late or lost the client sits on an empty document until its readiness deadline lapses and the file
|
||||
* opens read-only. Bounded by the longest a legitimate seed can take (the winner's own fetch bound),
|
||||
* which stays inside the client's readiness deadline — see {@link FILE_DOC_TIMEOUTS}.
|
||||
*/
|
||||
const SEED_WAIT_RETRY_MS = 150
|
||||
|
||||
async function runServerSeed(name: string, room: FileDocRoom, workspaceId: string): Promise<void> {
|
||||
const store = getFileDocStore()
|
||||
const deadline = Date.now() + FILE_DOC_TIMEOUTS.seedRequestMs
|
||||
while (fileDocRooms.get(name) === room && !isDocSeeded(room.doc)) {
|
||||
// Exactly one task across the cluster builds the seed; the others receive it via the stream (the
|
||||
// fix for split-brain seeding). Returns a lock token here (single-pod: a sentinel token).
|
||||
const token = await store.shouldSeed(name)
|
||||
if (token) {
|
||||
await seedUnderLock(name, room, workspaceId, token)
|
||||
return
|
||||
}
|
||||
// No token: a peer holds the lock with its fetch in flight, or the stream is already seeded (which
|
||||
// includes a PRIOR room for this same file whose seed landed after we read the stream). Either way
|
||||
// the seed can only appear in the stream, so read it rather than wait to be told.
|
||||
await store.catchUp(name)
|
||||
if (isDocSeeded(room.doc) || Date.now() >= deadline) return
|
||||
await sleep(SEED_WAIT_RETRY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch, publish, and apply the seed while holding the cluster's seed lock for this file. */
|
||||
async function seedUnderLock(
|
||||
name: string,
|
||||
room: FileDocRoom,
|
||||
workspaceId: string
|
||||
workspaceId: string,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
if (room.serverSeedStarted || isDocSeeded(room.doc)) return
|
||||
room.serverSeedStarted = true
|
||||
const store = getFileDocStore()
|
||||
// Exactly one task across the cluster builds the seed; the others receive it via the stream (the fix
|
||||
// for split-brain seeding). Returns a lock token here (single-pod: a sentinel token).
|
||||
const token = await store.shouldSeed(name)
|
||||
if (!token) {
|
||||
// A peer is seeding (or already did). Release our guard so a later join can retry if the seed never
|
||||
// arrives (e.g. the seeder died); the stream / this doc being seeded makes a retry safe.
|
||||
room.serverSeedStarted = false
|
||||
return
|
||||
}
|
||||
// We hold the seed lock — release it on EVERY exit from here (one `finally`, impossible to leak).
|
||||
// Release the lock on EVERY exit from here (one `finally`, impossible to leak).
|
||||
try {
|
||||
const seed = await fetchFileDocSeed(workspaceId, room.fileId)
|
||||
if (fileDocRooms.get(name) !== room || isDocSeeded(room.doc)) return
|
||||
@@ -533,15 +605,13 @@ async function ensureServerSeed(
|
||||
if (didSeed) {
|
||||
Y.applyUpdate(room.doc, seedUpdate, SEED_ORIGIN)
|
||||
} else {
|
||||
// A peer seeded first: its seed arrives via the tailer, so we must NOT apply our own — a second,
|
||||
// different-client-id seed IS the split-brain. Clear the guard so a later join can retry if that
|
||||
// peer seed somehow never lands (e.g. a fail-closed `xLen` error made `shouldSeed` skip a genuinely
|
||||
// empty stream); a real peer-seed makes the retry a no-op.
|
||||
room.serverSeedStarted = false
|
||||
// A peer won the atomic append: we must NOT apply our own — a second, different-client-id seed IS
|
||||
// the split-brain. Read THEIRS out of the stream instead of waiting for the tailer to deliver it,
|
||||
// so this room is seeded by the time the caller is told it is ready.
|
||||
await store.catchUp(name)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Server seed failed for file ${room.fileId} (workspace ${workspaceId})`, error)
|
||||
room.serverSeedStarted = false
|
||||
} finally {
|
||||
await store.releaseSeedLock(name, token)
|
||||
}
|
||||
@@ -725,12 +795,14 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom {
|
||||
// The server holds no cursor of its own; it only relays clients' awareness.
|
||||
awareness.setLocalState(null)
|
||||
|
||||
// Started BEFORE the room is registered so no join can observe a room without its hydration handle.
|
||||
const hydrated = getFileDocStore().attachRoom(name, doc)
|
||||
const room: FileDocRoom = {
|
||||
fileId: ref.id,
|
||||
doc,
|
||||
awareness,
|
||||
owners: new Map(),
|
||||
serverSeedStarted: false,
|
||||
seeding: null,
|
||||
workspaceId: null,
|
||||
lastEditorUserId: null,
|
||||
edited: false,
|
||||
@@ -739,6 +811,8 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom {
|
||||
persistDeadline: null,
|
||||
syncedVersion: null,
|
||||
agentStreamingUntil: 0,
|
||||
hydrated,
|
||||
pendingJoins: 0,
|
||||
}
|
||||
// Register synchronously BEFORE the async catch-up so a concurrent join sees this room, not a second.
|
||||
fileDocRooms.set(name, room)
|
||||
@@ -818,10 +892,6 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom {
|
||||
broadcast(io, name, encoding.toUint8Array(encoder), originSocketId(origin))
|
||||
})
|
||||
|
||||
// Load the shared state into the doc and start tailing the stream (fire-and-forget: content streams
|
||||
// in via `doc.on('update')` as it lands, mirroring the fire-and-forget seed below). Disabled → no-op.
|
||||
void getFileDocStore().attachRoom(name, doc)
|
||||
|
||||
return room
|
||||
}
|
||||
|
||||
@@ -1091,117 +1161,143 @@ export function setupWorkspaceFileDocHandlers(
|
||||
// awareness). Resolved here so the generation guard below also covers this await.
|
||||
const avatarUrl = await resolveAvatarUrl(socket, userId)
|
||||
|
||||
// Re-check access immediately before registering, mirroring the workflow join: the
|
||||
// access re-validation sweep records a revocation BEFORE it evicts, so a join that
|
||||
// authorized just before the revocation must not complete afterwards and re-bind
|
||||
// the socket to the document. This RE-RESOLVES rather than peeking the cache — a
|
||||
// peek treats an expired entry as unknown and fails open, which a join stalled
|
||||
// longer than the cache TTL would slip straight through. Normally a cache hit (this
|
||||
// join's own authorize just warmed it), so it costs no extra query.
|
||||
const currentPermission = await resolveCurrentRoomPermission(userId, room, FILE_DOC_ACTION)
|
||||
if (!satisfiesRoomMembership(currentPermission, ROOM_TYPES.WORKSPACE_FILE_DOC)) {
|
||||
logger.warn(`User ${userId} lost write access to file ${fileId} before the join completed`)
|
||||
emitJoinError(socket, fileId, 'Access denied to file', 'ACCESS_DENIED', false)
|
||||
return
|
||||
}
|
||||
|
||||
// Abort a JOIN superseded during authorization/identity resolution: the socket
|
||||
// disconnected, or a newer JOIN (a document switch) bumped the generation. Registering
|
||||
// here would leak a dead socket's room or bind the socket to the wrong document.
|
||||
// Last await before the commit, so nothing can interleave between the access
|
||||
// re-check above and the registration below.
|
||||
if (socket.disconnected || joinGeneration.get(socket.id) !== generation) return
|
||||
|
||||
const entry = getOrCreateRoom(io, room)
|
||||
// The workspace the server-side persist writes back to — and what the seed is built from, so it
|
||||
// must be captured BEFORE the room is prepared below.
|
||||
if (authorized.workspaceId) entry.workspaceId = authorized.workspaceId
|
||||
|
||||
// A client id must be owned by at most one user, or a peer could bind an active
|
||||
// collaborator's id and pass the per-frame ownership check to spoof/clear its caret.
|
||||
// Distinguish a reconnect from a spoof by the owning user: the same user reclaiming its
|
||||
// own client id (a dropped socket reconnecting reuses the Yjs client id, and its prior
|
||||
// socket may not be cleaned up yet) takes over the stale binding; a DIFFERENT user is
|
||||
// rejected. This runs BEFORE any teardown of the socket's current binding below, so a
|
||||
// rejected rebind — even during a document switch — leaves the socket's existing document
|
||||
// and caret untouched.
|
||||
for (const [otherSid, clientMap] of entry.owners) {
|
||||
if (otherSid === socket.id) continue
|
||||
const owner = clientMap.get(clientId)
|
||||
if (owner === undefined) continue
|
||||
if (owner.userId !== userId) {
|
||||
emitJoinError(socket, fileId, 'Client id already in use', 'CLIENT_ID_IN_USE', false)
|
||||
// Hold the room open across the awaits below: it has no owner until this join commits, so a
|
||||
// concurrent last-leave would otherwise tear down the very document being prepared.
|
||||
entry.pendingJoins += 1
|
||||
try {
|
||||
// A client is attached to a WHOLE document or to nothing. A room assembles itself from the
|
||||
// shared stream and the server seed, and both land in the same Y.Doc that fans every update out
|
||||
// to its room — so a socket attached mid-assembly is not sent the document, it is sent the
|
||||
// document's history, and it watches that replay on screen (reload right after moving a block
|
||||
// and the block moves again in front of you). Waiting here is what makes the handshake below
|
||||
// authoritative: the client's first sync IS the finished document, in one message.
|
||||
await ensureRoomReady(name, entry, entry.workspaceId)
|
||||
|
||||
// Re-check access immediately before registering, mirroring the workflow join: the
|
||||
// access re-validation sweep records a revocation BEFORE it evicts, so a join that
|
||||
// authorized just before the revocation must not complete afterwards and re-bind
|
||||
// the socket to the document. This RE-RESOLVES rather than peeking the cache — a
|
||||
// peek treats an expired entry as unknown and fails open, which a join stalled
|
||||
// longer than the cache TTL would slip straight through. Normally a cache hit (this
|
||||
// join's own authorize just warmed it), so it costs no extra query.
|
||||
const currentPermission = await resolveCurrentRoomPermission(userId, room, FILE_DOC_ACTION)
|
||||
if (!satisfiesRoomMembership(currentPermission, ROOM_TYPES.WORKSPACE_FILE_DOC)) {
|
||||
logger.warn(
|
||||
`User ${userId} lost write access to file ${fileId} before the join completed`
|
||||
)
|
||||
emitJoinError(socket, fileId, 'Access denied to file', 'ACCESS_DENIED', false)
|
||||
return
|
||||
}
|
||||
// Same user reclaiming its client id on a stale prior socket: evict just THAT clientID's binding
|
||||
// + caret from the old socket. If that leaves the old socket with no providers, also drop its
|
||||
// room mapping + Socket.IO membership so it can no longer send document (sync) frames
|
||||
// (handleMessage's SYNC path gates on socketToRoomName, not owners); an old socket that still
|
||||
// hosts OTHER providers keeps them. Done inline rather than via cleanupFileDocForSocket, which
|
||||
// could destroyRoomIfIdle the room we're joining.
|
||||
clientMap.delete(clientId)
|
||||
awarenessProtocol.removeAwarenessStates(entry.awareness, [clientId], null)
|
||||
if (clientMap.size === 0) {
|
||||
entry.owners.delete(otherSid)
|
||||
socketToRoomName.delete(otherSid)
|
||||
io.in(otherSid).socketsLeave(name)
|
||||
}
|
||||
}
|
||||
|
||||
// Only now that the rebind is guaranteed to succeed, leave a previously-joined document if
|
||||
// switching (a socket edits at most one). A duplicate join of the SAME room falls through
|
||||
// and simply re-runs the sync handshake, idempotently.
|
||||
const currentName = socketToRoomName.get(socket.id)
|
||||
if (currentName && currentName !== name) {
|
||||
socket.leave(currentName)
|
||||
cleanupFileDocForSocket(socket.id, io)
|
||||
}
|
||||
|
||||
// ADD this provider's clientID to the socket's ownership set (do NOT overwrite a sibling provider
|
||||
// on the same socket — that lone-owner overwrite is exactly what dropped the chat preview's
|
||||
// awareness when the Files editor co-mounted). A re-JOIN of the same clientID is idempotent. A
|
||||
// single provider that later unmounts clears its own caret via its awareness removal; the whole
|
||||
// set is dropped on the socket's LEAVE/disconnect (client emits LEAVE only after its LAST provider
|
||||
// for the file tears down).
|
||||
let clientMap = entry.owners.get(socket.id)
|
||||
if (clientMap === undefined) {
|
||||
clientMap = new Map<number, FileDocOwner>()
|
||||
entry.owners.set(socket.id, clientMap)
|
||||
}
|
||||
clientMap.set(clientId, { clientId, userId, userName, avatarUrl })
|
||||
socketToRoomName.set(socket.id, name)
|
||||
socket.join(name)
|
||||
|
||||
// Capture what the server-side persist needs: the workspace to write back to, and the current
|
||||
// user for attribution (refreshed to the actual editor on each edit in `handleMessage`).
|
||||
if (authorized.workspaceId) entry.workspaceId = authorized.workspaceId
|
||||
entry.lastEditorUserId = userId
|
||||
|
||||
socket.emit(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId })
|
||||
// Server-authenticated roster → everyone in the room, including this joiner.
|
||||
broadcastFileDocPresence(io, name, entry)
|
||||
|
||||
// Begin the sync handshake: send the server's state (sync step 1). The
|
||||
// client replies with its updates and requests the server's in return.
|
||||
const syncEncoder = encoding.createEncoder()
|
||||
encoding.writeVarUint(syncEncoder, FILE_DOC_MESSAGE_TYPE.SYNC)
|
||||
syncProtocol.writeSyncStep1(syncEncoder, entry.doc)
|
||||
socket.emit(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(syncEncoder))
|
||||
|
||||
// Send existing awareness so the new client immediately sees others' carets.
|
||||
const states = entry.awareness.getStates()
|
||||
if (states.size > 0) {
|
||||
const awarenessEncoder = encoding.createEncoder()
|
||||
encoding.writeVarUint(awarenessEncoder, FILE_DOC_MESSAGE_TYPE.AWARENESS)
|
||||
encoding.writeVarUint8Array(
|
||||
awarenessEncoder,
|
||||
awarenessProtocol.encodeAwarenessUpdate(entry.awareness, Array.from(states.keys()))
|
||||
// Abort a JOIN superseded while the room was being prepared: the socket disconnected, a newer
|
||||
// JOIN (a document switch) bumped the generation, or the room was dropped and re-created.
|
||||
// Registering here would leak a dead socket's room, bind the socket to the wrong document, or
|
||||
// attach it to a doc no longer registered. Last await before the commit, so nothing can
|
||||
// interleave between the access re-check above and the registration below.
|
||||
if (
|
||||
socket.disconnected ||
|
||||
joinGeneration.get(socket.id) !== generation ||
|
||||
fileDocRooms.get(name) !== entry
|
||||
)
|
||||
socket.emit(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(awarenessEncoder))
|
||||
return
|
||||
|
||||
// A client id must be owned by at most one user, or a peer could bind an active
|
||||
// collaborator's id and pass the per-frame ownership check to spoof/clear its caret.
|
||||
// Distinguish a reconnect from a spoof by the owning user: the same user reclaiming its
|
||||
// own client id (a dropped socket reconnecting reuses the Yjs client id, and its prior
|
||||
// socket may not be cleaned up yet) takes over the stale binding; a DIFFERENT user is
|
||||
// rejected. This runs BEFORE any teardown of the socket's current binding below, so a
|
||||
// rejected rebind — even during a document switch — leaves the socket's existing document
|
||||
// and caret untouched.
|
||||
for (const [otherSid, clientMap] of entry.owners) {
|
||||
if (otherSid === socket.id) continue
|
||||
const owner = clientMap.get(clientId)
|
||||
if (owner === undefined) continue
|
||||
if (owner.userId !== userId) {
|
||||
emitJoinError(socket, fileId, 'Client id already in use', 'CLIENT_ID_IN_USE', false)
|
||||
return
|
||||
}
|
||||
// Same user reclaiming its client id on a stale prior socket: evict just THAT clientID's
|
||||
// binding + caret from the old socket. If that leaves the old socket with no providers, also
|
||||
// drop its room mapping + Socket.IO membership so it can no longer send document (sync) frames
|
||||
// (handleMessage's SYNC path gates on socketToRoomName, not owners); an old socket that still
|
||||
// hosts OTHER providers keeps them. Done inline rather than via cleanupFileDocForSocket, which
|
||||
// could destroyRoomIfIdle the room we're joining.
|
||||
clientMap.delete(clientId)
|
||||
awarenessProtocol.removeAwarenessStates(entry.awareness, [clientId], null)
|
||||
if (clientMap.size === 0) {
|
||||
entry.owners.delete(otherSid)
|
||||
socketToRoomName.delete(otherSid)
|
||||
io.in(otherSid).socketsLeave(name)
|
||||
}
|
||||
}
|
||||
|
||||
// Only now that the rebind is guaranteed to succeed, leave a previously-joined document if
|
||||
// switching (a socket edits at most one). A duplicate join of the SAME room falls through
|
||||
// and simply re-runs the sync handshake, idempotently.
|
||||
const currentName = socketToRoomName.get(socket.id)
|
||||
if (currentName && currentName !== name) {
|
||||
socket.leave(currentName)
|
||||
cleanupFileDocForSocket(socket.id, io)
|
||||
}
|
||||
|
||||
// ADD this provider's clientID to the socket's ownership set (do NOT overwrite a sibling
|
||||
// provider on the same socket — that lone-owner overwrite is exactly what dropped the chat
|
||||
// preview's awareness when the Files editor co-mounted). A re-JOIN of the same clientID is
|
||||
// idempotent. A single provider that later unmounts clears its own caret via its awareness
|
||||
// removal; the whole set is dropped on the socket's LEAVE/disconnect (the client emits LEAVE
|
||||
// only after its LAST provider for the file tears down).
|
||||
let clientMap = entry.owners.get(socket.id)
|
||||
if (clientMap === undefined) {
|
||||
clientMap = new Map<number, FileDocOwner>()
|
||||
entry.owners.set(socket.id, clientMap)
|
||||
}
|
||||
clientMap.set(clientId, { clientId, userId, userName, avatarUrl })
|
||||
socketToRoomName.set(socket.id, name)
|
||||
socket.join(name)
|
||||
|
||||
// Attribution for the server-side persist, refreshed to the actual editor on each edit in
|
||||
// `handleMessage`.
|
||||
entry.lastEditorUserId = userId
|
||||
|
||||
// Name the document this room holds, so a client that still carries a DIFFERENT one (its room
|
||||
// outlived by a document rebuilt in its place) can refuse to merge instead of unioning two
|
||||
// documents into the file twice over. Read after readiness — before it, the room has no doc yet.
|
||||
socket.emit(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId, docId: docIdOf(entry.doc) })
|
||||
// Server-authenticated roster → everyone in the room, including this joiner.
|
||||
broadcastFileDocPresence(io, name, entry)
|
||||
|
||||
// Begin the sync handshake: send the server's state (sync step 1). The
|
||||
// client replies with its updates and requests the server's in return.
|
||||
const syncEncoder = encoding.createEncoder()
|
||||
encoding.writeVarUint(syncEncoder, FILE_DOC_MESSAGE_TYPE.SYNC)
|
||||
syncProtocol.writeSyncStep1(syncEncoder, entry.doc)
|
||||
socket.emit(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(syncEncoder))
|
||||
|
||||
// Send existing awareness so the new client immediately sees others' carets.
|
||||
const states = entry.awareness.getStates()
|
||||
if (states.size > 0) {
|
||||
const awarenessEncoder = encoding.createEncoder()
|
||||
encoding.writeVarUint(awarenessEncoder, FILE_DOC_MESSAGE_TYPE.AWARENESS)
|
||||
encoding.writeVarUint8Array(
|
||||
awarenessEncoder,
|
||||
awarenessProtocol.encodeAwarenessUpdate(entry.awareness, Array.from(states.keys()))
|
||||
)
|
||||
socket.emit(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(awarenessEncoder))
|
||||
}
|
||||
|
||||
logger.info(`User ${userId} joined file-doc room ${fileId}`)
|
||||
} finally {
|
||||
entry.pendingJoins -= 1
|
||||
// A join that returned without registering may have left behind the room it created; drop it
|
||||
// if nothing else claimed it. A no-op once this join committed (the room then has an owner).
|
||||
destroyRoomIfIdle(name)
|
||||
}
|
||||
|
||||
// Seed the document server-side (once). Fire-and-forget: the join completes immediately and
|
||||
// the seed relays to this socket via `doc.on('update')` the moment it lands.
|
||||
if (authorized.workspaceId) void ensureServerSeed(name, entry, authorized.workspaceId)
|
||||
|
||||
logger.info(`User ${userId} joined file-doc room ${fileId}`)
|
||||
} catch (error) {
|
||||
logger.error('Error joining file-doc room:', error)
|
||||
try {
|
||||
|
||||
@@ -197,6 +197,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
|
||||
# DATA_RETENTION_ENABLED= / NEXT_PUBLIC_DATA_RETENTION_ENABLED= # Runs retention deletion — off by default
|
||||
# DATA_DRAINS_ENABLED= / NEXT_PUBLIC_DATA_DRAINS_ENABLED= # Export streams
|
||||
# FORKING_ENABLED= # Workspace forks
|
||||
# CREDENTIAL_GROUPS= # Enterprise managed OAuth collections
|
||||
# ORGANIZATIONS_ENABLED= / NEXT_PUBLIC_ORGANIZATIONS_ENABLED= # Organizations only
|
||||
|
||||
# Instance organization (Optional). Most enterprise features read their settings from the
|
||||
|
||||
+2
-8
@@ -1,11 +1,5 @@
|
||||
import {
|
||||
AgentIcon,
|
||||
AnthropicIcon,
|
||||
GmailIcon,
|
||||
LinearIcon,
|
||||
SlackIcon,
|
||||
TableIcon,
|
||||
} from '@/components/icons'
|
||||
import { Table as TableIcon } from '@sim/emcn/icons'
|
||||
import { AgentIcon, AnthropicIcon, GmailIcon, LinearIcon, SlackIcon } from '@/components/icons'
|
||||
import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AgentIcon, CodeIcon, SlackIcon, StartIcon, TableIcon } from '@/components/icons'
|
||||
import { Table as TableIcon } from '@sim/emcn/icons'
|
||||
import { AgentIcon, CodeIcon, SlackIcon, StartIcon } from '@/components/icons'
|
||||
import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
|
||||
import { BLOCK_WIDTH } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
|
||||
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { WorkflowBlock } from '@/app/(landing)/components/hero/components/hero-visual/workflow-block'
|
||||
import {
|
||||
BLOCK_WIDTH,
|
||||
BLOCKS,
|
||||
CANVAS,
|
||||
EDGES,
|
||||
WORKFLOW_FOCUS_SCALE,
|
||||
} from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
|
||||
|
||||
/** The camera beat: held on the first block, panning out, or settled on the whole flow. */
|
||||
export type WorkflowCameraStage = 'focus' | 'out' | 'hold'
|
||||
|
||||
interface StageWorkflowProps {
|
||||
stage: WorkflowCameraStage
|
||||
}
|
||||
|
||||
/** First (GitHub) block center in design space - the camera's focus target. */
|
||||
const FOCUS_CENTER = { x: BLOCK_WIDTH / 2, y: 38 }
|
||||
/** Whole-canvas center - the overview camera target. */
|
||||
const CANVAS_CENTER = { x: CANVAS.width / 2, y: CANVAS.height / 2 }
|
||||
/** Zoomed-in scale while held on the first block (≈ the morphed chat card size). */
|
||||
const FOCUS_SCALE = WORKFLOW_FOCUS_SCALE
|
||||
/** Pulled-back scale that fits the whole workflow in the panel. */
|
||||
const OVERVIEW_SCALE = 0.68
|
||||
|
||||
/**
|
||||
* The workflow stage of the hero visual - a design-space canvas with a moving
|
||||
* "camera". It opens **focused** on the first block (the chat card has just
|
||||
* morphed into it), holds while that block's content lands and the first edge
|
||||
* draws, then the camera **pans + zooms out together** to reveal the whole
|
||||
* GitHub → Agent → Jira flow (the {@link stage} prop drives this).
|
||||
*
|
||||
* The camera is a transform on the design-space canvas, positioned so the focus
|
||||
* point lands at the panel center: `translate(vpW/2 - cx·s, vpH/2 - cy·s)
|
||||
* scale(s)` (origin top-left). The panel size is measured; until it is known,
|
||||
* and on first mount, the transition is suppressed so the opening focus frame
|
||||
* doesn't animate in from a fallback. Purely decorative - `aria-hidden`.
|
||||
*/
|
||||
export function StageWorkflow({ stage }: StageWorkflowProps) {
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
const [vp, setVp] = useState<{ w: number; h: number } | null>(null)
|
||||
const [animate, setAnimate] = useState(false)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = viewportRef.current
|
||||
if (!el) return
|
||||
const measure = () => {
|
||||
const r = el.getBoundingClientRect()
|
||||
// Guard an unpainted/collapsed panel from poisoning the camera math.
|
||||
if (r.width > 120 && r.height > 120) setVp({ w: r.width, h: r.height })
|
||||
}
|
||||
measure()
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
// Enable the camera transition only after the opening focus frame is painted,
|
||||
// so mounting (and the first measurement) snaps into focus rather than gliding.
|
||||
useEffect(() => {
|
||||
if (vp) setAnimate(true)
|
||||
}, [vp])
|
||||
|
||||
const focused = stage === 'focus'
|
||||
const center = focused ? FOCUS_CENTER : CANVAS_CENTER
|
||||
const scale = focused ? FOCUS_SCALE : OVERVIEW_SCALE
|
||||
const transform = vp
|
||||
? `translate(${vp.w / 2 - center.x * scale}px, ${vp.h / 2 - center.y * scale}px) scale(${scale})`
|
||||
: `translate(0px, 0px) scale(${OVERVIEW_SCALE})`
|
||||
|
||||
return (
|
||||
<div ref={viewportRef} className='relative h-full w-full overflow-hidden'>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-0 left-0',
|
||||
animate &&
|
||||
'transition-transform [transition-duration:1700ms] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)]'
|
||||
)}
|
||||
style={{
|
||||
width: CANVAS.width,
|
||||
height: CANVAS.height,
|
||||
transform,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className='absolute inset-0'
|
||||
width={CANVAS.width}
|
||||
height={CANVAS.height}
|
||||
viewBox={`0 0 ${CANVAS.width} ${CANVAS.height}`}
|
||||
fill='none'
|
||||
aria-hidden='true'
|
||||
>
|
||||
{EDGES.map((edge, i) => (
|
||||
<path
|
||||
key={edge.id}
|
||||
d={edge.d}
|
||||
pathLength={1}
|
||||
stroke='var(--workflow-edge)'
|
||||
strokeWidth={2}
|
||||
strokeLinecap='round'
|
||||
className='transition-[stroke-dashoffset] [stroke-dasharray:1] [transition-duration:700ms] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)]'
|
||||
// Edges stay undrawn while focused; once the camera pulls out they
|
||||
// draw in order (the second trails the first) as each target is revealed.
|
||||
style={
|
||||
{
|
||||
strokeDashoffset: focused ? 1 : 0,
|
||||
transitionDelay: `${i * 700}ms`,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
{BLOCKS.map((block) => (
|
||||
// The first block is already on screen - the chat card morphed into it,
|
||||
// and the focused camera lands it pixel-matched here; the rest sit in
|
||||
// design space and are revealed by the camera pull-out.
|
||||
<div
|
||||
key={block.id}
|
||||
className='absolute'
|
||||
style={{ left: block.x, top: block.y, width: BLOCK_WIDTH }}
|
||||
>
|
||||
<WorkflowBlock block={block} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { SidebarView } from '@/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
|
||||
import { useLazyMount } from '@/app/(landing)/hooks/use-lazy-mount'
|
||||
|
||||
/** Dimension-stable placeholder sized to the preview's exact footprint (zero CLS). */
|
||||
const PLACEHOLDER_CLASS = 'aspect-[1116/615] w-full rounded bg-[var(--surface-1)]'
|
||||
|
||||
/**
|
||||
* Client mount for the {@link LandingPreview} - the heavy, animated workspace
|
||||
* island (framer-motion + reactflow). Isolated here so the sections that show it
|
||||
* stay Server Components: only this leaf is `'use client'`.
|
||||
*
|
||||
* Loaded with `ssr: false` so the framer-motion/reactflow bundle never ships in
|
||||
* the server-rendered HTML, and gated on viewport proximity via
|
||||
* {@link useLazyMount} so the below-the-fold previews don't pull the heavy
|
||||
* bundle into the initial homepage load. A dimension-stable placeholder (the
|
||||
* preview's exact `aspect-[1116/615]` footprint, filled with the canvas
|
||||
* surface) holds the space before and during load, so there is zero layout
|
||||
* shift or flash.
|
||||
*/
|
||||
const LandingPreview = dynamic(
|
||||
() =>
|
||||
import('@/app/(landing)/components/landing-preview/landing-preview').then(
|
||||
(mod) => mod.LandingPreview
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className={PLACEHOLDER_CLASS} />,
|
||||
}
|
||||
)
|
||||
|
||||
interface LandingPreviewMountProps {
|
||||
/** Forwarded to {@link LandingPreview}; `false` renders a static snapshot. */
|
||||
autoplay?: boolean
|
||||
/** Forwarded to {@link LandingPreview}; the static snapshot's staged view. */
|
||||
view?: SidebarView
|
||||
/** Forwarded to {@link LandingPreview}; the static snapshot's workflow. */
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
export function LandingPreviewMount({ autoplay, view, workflowId }: LandingPreviewMountProps) {
|
||||
const { ref, inView } = useLazyMount('400px')
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{inView ? (
|
||||
<LandingPreview autoplay={autoplay} initialView={view} initialWorkflowId={workflowId} />
|
||||
) : (
|
||||
<div className={PLACEHOLDER_CLASS} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-1
@@ -1,7 +1,6 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
export const ISO_LINE_STROKE_WIDTH = 3.2
|
||||
export const ISO_ENDPOINT_STROKE_WIDTH = 3.3
|
||||
export const ISO_STROKE = 'color-mix(in srgb, var(--text-subtle) 76%, var(--text-muted))'
|
||||
export const ISO_FILL_LOW = 'var(--surface-6)'
|
||||
export const ISO_FILL_MID = 'color-mix(in srgb, var(--surface-3) 58%, var(--surface-6))'
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { WORDMARK_PATHS, WORDMARK_VIEW_BOX } from '@/lib/branding/wordmark'
|
||||
|
||||
/**
|
||||
* Inline "sim" brand logotype (wordmark, no separate icon mark) - the paths
|
||||
* from the v1.0 brand guide's `simLogotype--dark.svg`, inlined so the logo
|
||||
* ships as zero-request server-rendered HTML.
|
||||
* ships as zero-request server-rendered HTML. They live in
|
||||
* `@/lib/branding/wordmark` because the email header rasterizes the same
|
||||
* outlines.
|
||||
*
|
||||
* Filled with a single solid `var(--text-body)` - the navbar's own text color
|
||||
* (the same token its nav-link chips use) - so the wordmark reads as one solid
|
||||
@@ -14,7 +18,7 @@
|
||||
export function SimWordmark() {
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 441 212'
|
||||
viewBox={`0 0 ${WORDMARK_VIEW_BOX.width} ${WORDMARK_VIEW_BOX.height}`}
|
||||
width={37}
|
||||
height={18}
|
||||
fill='none'
|
||||
@@ -22,10 +26,9 @@ export function SimWordmark() {
|
||||
className='-translate-y-[1.5px] h-[18px] w-auto'
|
||||
>
|
||||
<g fill='var(--text-body)'>
|
||||
<path d='M0 160.9H29.51C29.51 169.08 32.46 175.61 38.37 180.48C44.27 185.12 52.25 187.44 62.31 187.44C73.24 187.44 81.65 185.34 87.56 181.14C93.46 176.71 96.41 170.85 96.41 163.55C96.41 158.24 94.77 153.82 91.49 150.28C88.43 146.74 82.75 143.86 74.44 141.65L46.24 135.01C32.03 131.47 21.42 126.05 14.43 118.75C7.65 111.45 4.26 101.83 4.26 89.88C4.26 79.93 6.78 71.3 11.81 64C17.05 56.7 24.16 51.06 33.12 47.08C42.3 43.09 52.8 41.1 64.6 41.1C76.41 41.1 86.57 43.2 95.1 47.41C103.84 51.61 110.62 57.47 115.43 64.99C120.46 72.52 123.08 81.48 123.3 91.87H93.79C93.57 83.47 90.84 76.94 85.59 72.3C80.34 67.65 73.02 65.33 63.62 65.33C54 65.33 46.57 67.43 41.32 71.63C36.07 75.83 33.45 81.59 33.45 88.89C33.45 99.73 41.32 107.14 57.06 111.12L85.26 118.09C98.81 121.19 108.98 126.28 115.76 133.35C122.53 140.21 125.92 149.61 125.92 161.56C125.92 171.74 123.19 180.7 117.73 188.44C112.26 195.96 104.72 201.82 95.1 206.03C85.7 210.01 74.55 212 61.65 212C42.85 212 27.87 207.35 16.72 198.06C5.57 188.77 0 176.38 0 160.9Z' />
|
||||
<path d='M232.8 212H202.13L202.13 49.76H229.54V77.39C232.8 68.34 239.11 60.66 247.81 54.7C256.73 48.52 267.5 45.43 280.12 45.43C294.26 45.43 306.01 49.29 315.36 57.02C324.72 64.75 330.81 75.01 333.64 87.82H328.09C330.27 75.01 336.25 64.75 346.04 57.02C355.83 49.29 367.9 45.43 382.26 45.43C400.54 45.43 414.89 50.84 425.34 61.66C435.78 72.47 441 87.26 441 106.03V212H410.98V113.65C410.98 100.84 407.71 91.02 401.19 84.17C394.88 77.11 386.29 73.58 375.41 73.58C367.79 73.58 361.05 75.34 355.17 78.88C349.52 82.19 345.06 87.04 341.8 93.45C338.53 99.85 336.9 107.36 336.9 115.97V212H306.55V113.32C306.55 100.51 303.4 90.8 297.09 84.17C290.78 77.33 282.19 73.91 271.31 73.91C263.69 73.91 256.95 75.67 251.08 79.21C245.42 82.52 240.96 87.38 237.7 93.78C234.43 99.96 232.8 107.36 232.8 115.97V212Z' />
|
||||
<path d='M184.83 20.55C184.83 31.9 175.64 41.1 164.29 41.1C152.95 41.1 143.76 31.9 143.76 20.55C143.76 9.2 152.95 0 164.29 0C175.64 0 184.83 9.2 184.83 20.55Z' />
|
||||
<path d='M179.43 212H149.16V49.76C153.76 51.91 158.88 53.12 164.29 53.12C169.7 53.12 174.83 51.91 179.43 49.76V212Z' />
|
||||
{WORDMARK_PATHS.map((d) => (
|
||||
<path key={d} d={d} />
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AgentIcon, ConditionalIcon, MailIcon, StartIcon, TableIcon } from '@/components/icons'
|
||||
import { Table as TableIcon } from '@sim/emcn/icons'
|
||||
import { AgentIcon, ConditionalIcon, MailIcon, StartIcon } from '@/components/icons'
|
||||
import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { AgentIcon, ConditionalIcon, ScheduleIcon, SlackIcon, TableIcon } from '@/components/icons'
|
||||
import { Table as TableIcon } from '@sim/emcn/icons'
|
||||
import { AgentIcon, ConditionalIcon, ScheduleIcon, SlackIcon } from '@/components/icons'
|
||||
import { EnterprisePlatformLoop } from '@/app/(landing)/enterprise/components/enterprise-platform-loop'
|
||||
import type { EnterpriseLoopContent } from '@/app/(landing)/enterprise/components/enterprise-platform-loop/stage-data'
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { AgentIcon, ConditionalIcon, MailIcon, StartIcon, TableIcon } from '@/components/icons'
|
||||
import { Table as TableIcon } from '@sim/emcn/icons'
|
||||
import { AgentIcon, ConditionalIcon, MailIcon, StartIcon } from '@/components/icons'
|
||||
import { EnterprisePlatformLoop } from '@/app/(landing)/enterprise/components/enterprise-platform-loop'
|
||||
import type { EnterpriseLoopContent } from '@/app/(landing)/enterprise/components/enterprise-platform-loop/stage-data'
|
||||
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
AgentIcon,
|
||||
ConditionalIcon,
|
||||
JiraIcon,
|
||||
SlackIcon,
|
||||
StartIcon,
|
||||
TableIcon,
|
||||
} from '@/components/icons'
|
||||
import { Table as TableIcon } from '@sim/emcn/icons'
|
||||
import { AgentIcon, ConditionalIcon, JiraIcon, SlackIcon, StartIcon } from '@/components/icons'
|
||||
import { EditorLoop, type EditorLoopContent } from '@/app/(landing)/components/shared/editor-loop'
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defaultShouldDehydrateQuery, isServer, QueryClient } from '@tanstack/react-query'
|
||||
import { isServer, QueryClient } from '@tanstack/react-query'
|
||||
import { isDesktopApp } from '@/lib/desktop'
|
||||
|
||||
export function makeQueryClient() {
|
||||
@@ -6,7 +6,6 @@ export function makeQueryClient() {
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
// The desktop app window lives for days, so cross-session changes —
|
||||
// an admin upgrading your org/workspace role, a workspace you were
|
||||
// auto-added to, seat/entitlement changes — would otherwise stay
|
||||
@@ -18,16 +17,19 @@ export function makeQueryClient() {
|
||||
// frequent and noisy. Per-query overrides (e.g. useWorkspaceSchedules
|
||||
// pins this off) always win over this default.
|
||||
refetchOnWindowFocus: isDesktopApp(),
|
||||
retry: 1,
|
||||
/**
|
||||
* Query core already defaults retries to 0 on the server and 3 in the browser;
|
||||
* only the browser number is ours to change. Stating one value for both would
|
||||
* silently opt server prefetches into a retry, and because the layout awaits
|
||||
* them that spends a retry backoff of document latency on a read whose failure
|
||||
* the client recovers from on its own.
|
||||
*/
|
||||
retry: isServer ? 0 : 1,
|
||||
retryOnMount: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
dehydrate: {
|
||||
shouldDehydrateQuery: (query) =>
|
||||
defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -42,6 +42,28 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
||||
password: true,
|
||||
email: false,
|
||||
},
|
||||
/**
|
||||
* None of these nodes are painted, so replay fidelity is
|
||||
* unchanged, while each full snapshot serializes fewer nodes on
|
||||
* the main thread and ships a smaller payload.
|
||||
*
|
||||
* Enumerated rather than `true`/`'all'` on purpose — those
|
||||
* presets also enable `headTitleMutations`, which would drop
|
||||
* `document.title` changes and lose the page identity a replay
|
||||
* viewer reads while scrubbing.
|
||||
*/
|
||||
slimDOMOptions: {
|
||||
script: true,
|
||||
comment: true,
|
||||
headFavicon: true,
|
||||
headWhitespace: true,
|
||||
headMetaDescKeywords: true,
|
||||
headMetaSocial: true,
|
||||
headMetaRobots: true,
|
||||
headMetaHttpEquiv: true,
|
||||
headMetaAuthorship: true,
|
||||
headMetaVerification: true,
|
||||
},
|
||||
recordCrossOriginIframes: false,
|
||||
recordHeaders: false,
|
||||
recordBody: false,
|
||||
|
||||
@@ -3,13 +3,26 @@ import localFont from 'next/font/local'
|
||||
/**
|
||||
* Season Sans variable font configuration
|
||||
* Uses variable font file to support any weight from 300-800
|
||||
*
|
||||
* `display: 'block'`, not `swap`: this is the document font, so a swap is not a cosmetic change of
|
||||
* typeface — the fallback's glyph advances differ, so paragraphs re-wrap and everything below them
|
||||
* moves. In long-form prose (the Files editor) that reads as the line and paragraph spacing visibly
|
||||
* correcting itself a beat after the text appears, on every hard refresh (a normal reload serves the
|
||||
* font from cache and never swaps). `swap` is the setting that says "painting the wrong font first is
|
||||
* fine"; for a brand face it is not.
|
||||
*
|
||||
* The block period costs nothing here because delivery is already optimal: `preload` emits a
|
||||
* `Link: rel=preload` RESPONSE header, so the fetch starts before the HTML is parsed, and the file is
|
||||
* one same-origin, immutably-cached 87KB woff2. The metric-adjusted Arial below stays as the safety
|
||||
* net for the >3s tail, where the browser gives up blocking and swaps — i.e. the worst case is
|
||||
* today's behavior, not a regression.
|
||||
*/
|
||||
export const season = localFont({
|
||||
src: [
|
||||
// Variable font - supports all weights from 300 to 800
|
||||
{ path: './SeasonSansUprightsVF.woff2', weight: '300 800', style: 'normal' },
|
||||
],
|
||||
display: 'swap',
|
||||
display: 'block',
|
||||
preload: true,
|
||||
variable: '--font-season',
|
||||
fallback: ['system-ui', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'Noto Sans'],
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
:root {
|
||||
--sidebar-width: 0px; /* 0 outside workspace; blocking script always sets actual value on workspace pages */
|
||||
--sidebar-collapsed-width: 51px; /* icon rail on web; desktop overrides to 0 before first paint */
|
||||
--sidebar-collapsed-width: 48px; /* icon rail on web; desktop overrides to 0 before first paint */
|
||||
--sidebar-expanded-width: 238px; /* SIDEBAR_WIDTH.DEFAULT; the width to restore to, held even while collapsed */
|
||||
--desktop-title-bar-height: 0px; /* macOS traffic-light lane; desktop overrides before first paint */
|
||||
--workspace-content-title-bar-inset: 0px; /* lane the content pane must leave clear; only non-zero when the pane, not the sidebar, sits under it */
|
||||
|
||||
@@ -1,61 +1,23 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account, credential } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { connectedAccountsQuerySchema } from '@/lib/api/contracts/oauth-connections'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { listConnectedAccountsContract } from '@/lib/api/contracts/oauth-connections'
|
||||
import {
|
||||
defineInternalJsonRoute,
|
||||
internalRateLimits,
|
||||
internalSessionAuth,
|
||||
} from '@/lib/api/server/routes'
|
||||
import {
|
||||
credentialValidationParseOptions,
|
||||
internalCredentialErrorPolicy,
|
||||
} from '@/lib/credentials/api/route-policies'
|
||||
import { listConnectedAccountsUseCase } from '@/lib/credentials/application/oauth-accounts'
|
||||
import { credentialUserOperations } from '@/lib/credentials/application/operations'
|
||||
|
||||
const logger = createLogger('AuthAccountsAPI')
|
||||
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const { provider } = connectedAccountsQuerySchema.parse({
|
||||
provider: searchParams.get('provider') || undefined,
|
||||
})
|
||||
|
||||
const whereConditions = [eq(account.userId, session.user.id)]
|
||||
|
||||
if (provider) {
|
||||
whereConditions.push(eq(account.providerId, provider))
|
||||
}
|
||||
|
||||
const accounts = await db
|
||||
.select({
|
||||
id: account.id,
|
||||
accountId: account.accountId,
|
||||
providerId: account.providerId,
|
||||
credentialDisplayName: credential.displayName,
|
||||
})
|
||||
.from(account)
|
||||
.leftJoin(credential, eq(credential.accountId, account.id))
|
||||
.where(and(...whereConditions))
|
||||
.orderBy(desc(account.updatedAt))
|
||||
|
||||
const seen = new Map<string, (typeof accounts)[number]>()
|
||||
for (const acc of accounts) {
|
||||
if (!seen.has(acc.id)) {
|
||||
seen.set(acc.id, acc)
|
||||
}
|
||||
}
|
||||
|
||||
const accountsWithDisplayName = Array.from(seen.values()).map((acc) => ({
|
||||
id: acc.id,
|
||||
accountId: acc.accountId,
|
||||
providerId: acc.providerId,
|
||||
displayName: acc.credentialDisplayName || acc.accountId || acc.providerId,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ accounts: accountsWithDisplayName })
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch accounts', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
export const GET = defineInternalJsonRoute({
|
||||
contract: listConnectedAccountsContract,
|
||||
auth: internalSessionAuth,
|
||||
operation: credentialUserOperations.listConnectedAccounts,
|
||||
rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }),
|
||||
errorPolicy: internalCredentialErrorPolicy,
|
||||
parseOptions: credentialValidationParseOptions,
|
||||
mapInput: ({ query }) => query,
|
||||
useCase: listConnectedAccountsUseCase,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createMockRequest } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createCredentialConnection: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
requireConfiguredOAuthClient: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
getSession: mocks.getSession,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env-capabilities.server', () => ({
|
||||
requireConfiguredOAuthClient: mocks.requireConfiguredOAuthClient,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/utils/urls', () => ({
|
||||
getBaseUrl: () => 'https://sim.test',
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credentials/application/create-credential-connection', () => ({
|
||||
createCredentialConnection: { execute: mocks.createCredentialConnection },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/oauth/utils', () => ({
|
||||
getCanonicalScopesForProvider: () => ['instagram_business_basic'],
|
||||
}))
|
||||
|
||||
import { OrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import { GET } from '@/app/api/auth/instagram/authorize/route'
|
||||
|
||||
describe('Instagram authorize route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getSession.mockResolvedValue({
|
||||
user: { id: 'user-1' },
|
||||
session: { id: 'session-1' },
|
||||
})
|
||||
mocks.requireConfiguredOAuthClient.mockReturnValue({
|
||||
values: { INSTAGRAM_CLIENT_ID: 'instagram-client' },
|
||||
})
|
||||
mocks.createCredentialConnection.mockResolvedValue({ draftId: 'draft-created' })
|
||||
})
|
||||
|
||||
it('preserves an exact credential draft when workspaceId is also supplied', async () => {
|
||||
const request = createMockRequest(
|
||||
'GET',
|
||||
undefined,
|
||||
{},
|
||||
'https://sim.test/api/auth/instagram/authorize?workspaceId=workspace-1&draftId=draft-exact'
|
||||
)
|
||||
|
||||
const response = await GET(request)
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get('set-cookie')).toContain(
|
||||
'instagram_credential_draft_id=draft-exact'
|
||||
)
|
||||
expect(mocks.createCredentialConnection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates a credential draft for a legacy workspace-only launch', async () => {
|
||||
const request = createMockRequest(
|
||||
'GET',
|
||||
undefined,
|
||||
{},
|
||||
'https://sim.test/api/auth/instagram/authorize?workspaceId=workspace-1'
|
||||
)
|
||||
|
||||
const response = await GET(request)
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get('set-cookie')).toContain(
|
||||
'instagram_credential_draft_id=draft-created'
|
||||
)
|
||||
expect(response.headers.get('set-cookie')).toContain('Max-Age=900')
|
||||
expect(mocks.createCredentialConnection).toHaveBeenCalledWith({
|
||||
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
|
||||
input: { workspaceId: 'workspace-1', providerId: 'instagram' },
|
||||
request,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a conflict when a different connection intent is already active', async () => {
|
||||
mocks.createCredentialConnection.mockRejectedValue(
|
||||
new OrchestrationError(
|
||||
'conflict',
|
||||
'A different OAuth connection flow is already active for this provider'
|
||||
)
|
||||
)
|
||||
const request = createMockRequest(
|
||||
'GET',
|
||||
undefined,
|
||||
{},
|
||||
'https://sim.test/api/auth/instagram/authorize?workspaceId=workspace-1'
|
||||
)
|
||||
|
||||
const response = await GET(request)
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
error: 'A different OAuth connection flow is already active for this provider',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,12 +5,13 @@ import { authorizeInstagramContract } from '@/lib/api/contracts/oauth-connection
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server'
|
||||
import { asOrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { isSameOrigin } from '@/lib/core/utils/validation'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createConnectDraft } from '@/lib/credentials/connect-draft'
|
||||
import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection'
|
||||
import { CREDENTIAL_DRAFT_TTL_SECONDS } from '@/lib/credentials/draft-constants'
|
||||
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('InstagramAuthorize')
|
||||
|
||||
@@ -18,8 +19,8 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
const INSTAGRAM_STATE_COOKIE = 'instagram_oauth_state'
|
||||
const INSTAGRAM_RETURN_URL_COOKIE = 'instagram_return_url'
|
||||
const INSTAGRAM_CREDENTIAL_DRAFT_COOKIE = 'instagram_credential_draft_id'
|
||||
const INSTAGRAM_STATE_COOKIE_PATH = '/api/auth'
|
||||
const INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS = 60 * 10
|
||||
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
@@ -27,6 +28,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const sessionId = session.session?.id
|
||||
if (!sessionId) throw new Error('Authenticated session is missing its session ID')
|
||||
|
||||
const {
|
||||
values: { INSTAGRAM_CLIENT_ID: clientId },
|
||||
@@ -34,18 +37,31 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
|
||||
const parsed = await parseRequest(authorizeInstagramContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { returnUrl, workspaceId } = parsed.data.query
|
||||
const { returnUrl, workspaceId, draftId } = parsed.data.query
|
||||
let credentialDraftId = draftId
|
||||
|
||||
if (workspaceId) {
|
||||
const access = await checkWorkspaceAccess(workspaceId, session.user.id)
|
||||
if (!access.canWrite) {
|
||||
return NextResponse.json({ error: 'Workspace write access denied' }, { status: 403 })
|
||||
if (workspaceId && !draftId) {
|
||||
try {
|
||||
const connection = await createCredentialConnection.execute({
|
||||
principal: { kind: 'session', userId: session.user.id, sessionId },
|
||||
input: { workspaceId, providerId: 'instagram' },
|
||||
request,
|
||||
})
|
||||
credentialDraftId = connection.draftId
|
||||
} catch (error) {
|
||||
const classified = asOrchestrationError(error)
|
||||
if (classified?.code === 'conflict') {
|
||||
logger.warn('Rejected conflicting Instagram OAuth connection intent', {
|
||||
userId: session.user.id,
|
||||
workspaceId,
|
||||
})
|
||||
return NextResponse.json({ error: classified.message }, { status: 409 })
|
||||
}
|
||||
if (classified?.code === 'forbidden' || classified?.code === 'not_found') {
|
||||
return NextResponse.json({ error: 'Workspace write access denied' }, { status: 403 })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
await createConnectDraft({
|
||||
userId: session.user.id,
|
||||
workspaceId,
|
||||
providerId: 'instagram',
|
||||
})
|
||||
}
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
@@ -65,16 +81,30 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS,
|
||||
maxAge: CREDENTIAL_DRAFT_TTL_SECONDS,
|
||||
path: INSTAGRAM_STATE_COOKIE_PATH,
|
||||
})
|
||||
if (credentialDraftId) {
|
||||
response.cookies.set(INSTAGRAM_CREDENTIAL_DRAFT_COOKIE, credentialDraftId, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: CREDENTIAL_DRAFT_TTL_SECONDS,
|
||||
path: INSTAGRAM_STATE_COOKIE_PATH,
|
||||
})
|
||||
} else {
|
||||
response.cookies.delete({
|
||||
name: INSTAGRAM_CREDENTIAL_DRAFT_COOKIE,
|
||||
path: INSTAGRAM_STATE_COOKIE_PATH,
|
||||
})
|
||||
}
|
||||
|
||||
if (returnUrl && isSameOrigin(returnUrl)) {
|
||||
response.cookies.set(INSTAGRAM_RETURN_URL_COOKIE, returnUrl, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS,
|
||||
maxAge: CREDENTIAL_DRAFT_TTL_SECONDS,
|
||||
path: INSTAGRAM_STATE_COOKIE_PATH,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ describe('OAuth Connections API Route', () => {
|
||||
it('should return connections successfully', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValueOnce({
|
||||
user: { id: 'user-123' },
|
||||
session: { id: 'session-1' },
|
||||
})
|
||||
|
||||
const mockAccounts = [
|
||||
@@ -105,12 +106,13 @@ describe('OAuth Connections API Route', () => {
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(data.error).toBe('User not authenticated')
|
||||
expect(data.error).toBe('Unauthorized')
|
||||
})
|
||||
|
||||
it('should handle user with no connections', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValueOnce({
|
||||
user: { id: 'user-123' },
|
||||
session: { id: 'session-1' },
|
||||
})
|
||||
|
||||
dbChainMockFns.where.mockResolvedValueOnce([])
|
||||
@@ -128,6 +130,7 @@ describe('OAuth Connections API Route', () => {
|
||||
it('should handle database error', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValueOnce({
|
||||
user: { id: 'user-123' },
|
||||
session: { id: 'session-1' },
|
||||
})
|
||||
|
||||
dbChainMockFns.where.mockRejectedValueOnce(new Error('Database error'))
|
||||
@@ -144,6 +147,7 @@ describe('OAuth Connections API Route', () => {
|
||||
it('should decode ID token for display name', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValueOnce({
|
||||
user: { id: 'user-123' },
|
||||
session: { id: 'session-1' },
|
||||
})
|
||||
|
||||
const mockAccounts = [
|
||||
|
||||
@@ -1,139 +1,19 @@
|
||||
import { account, db, user } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { decodeJwt } from 'jose'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import type { OAuthConnection } from '@/lib/api/contracts/oauth-connections'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { OAuthProvider } from '@/lib/oauth'
|
||||
import { parseProvider } from '@/lib/oauth'
|
||||
import { listOAuthConnectionsContract } from '@/lib/api/contracts/oauth-connections'
|
||||
import {
|
||||
defineInternalJsonRoute,
|
||||
internalRateLimits,
|
||||
internalSessionAuth,
|
||||
} from '@/lib/api/server/routes'
|
||||
import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies'
|
||||
import { listOAuthConnectionsUseCase } from '@/lib/credentials/application/oauth-accounts'
|
||||
import { credentialUserOperations } from '@/lib/credentials/application/operations'
|
||||
|
||||
const logger = createLogger('OAuthConnectionsAPI')
|
||||
|
||||
interface GoogleIdToken {
|
||||
email?: string
|
||||
sub?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all OAuth connections for the current user
|
||||
*/
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
// Get the session
|
||||
const session = await getSession()
|
||||
|
||||
// Check if the user is authenticated
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthenticated request rejected`)
|
||||
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get all accounts for this user
|
||||
const accounts = await db.select().from(account).where(eq(account.userId, session.user.id))
|
||||
|
||||
// Get the user's email for fallback
|
||||
const userRecord = await db
|
||||
.select({ email: user.email })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
const userEmail = userRecord.length > 0 ? userRecord[0]?.email : null
|
||||
|
||||
// Process accounts to determine connections
|
||||
const connections: OAuthConnection[] = []
|
||||
|
||||
for (const acc of accounts) {
|
||||
const { baseProvider, featureType } = parseProvider(acc.providerId as OAuthProvider)
|
||||
const scopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : []
|
||||
|
||||
if (baseProvider) {
|
||||
// Try multiple methods to get a user-friendly display name
|
||||
let displayName = ''
|
||||
|
||||
// Method 1: Try to extract email from ID token (works for Google, etc.)
|
||||
if (acc.idToken) {
|
||||
try {
|
||||
const decoded = decodeJwt<GoogleIdToken>(acc.idToken)
|
||||
if (decoded.email) {
|
||||
displayName = decoded.email
|
||||
} else if (decoded.name) {
|
||||
displayName = decoded.name
|
||||
}
|
||||
} catch (_error) {
|
||||
logger.warn(`[${requestId}] Error decoding ID token`, {
|
||||
accountId: acc.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: For GitHub, the accountId might be the username
|
||||
if (!displayName && baseProvider === 'github') {
|
||||
displayName = `${acc.accountId} (GitHub)`
|
||||
}
|
||||
|
||||
// Method 3: Use the user's email from our database
|
||||
if (!displayName && userEmail) {
|
||||
displayName = userEmail
|
||||
}
|
||||
|
||||
// Fallback: Use accountId with provider type as context
|
||||
if (!displayName) {
|
||||
displayName = `${acc.accountId} (${baseProvider})`
|
||||
}
|
||||
|
||||
// Create a unique connection key that includes the full provider ID
|
||||
const connectionKey = acc.providerId
|
||||
|
||||
// Find existing connection for this specific provider ID
|
||||
const existingConnection = connections.find((conn) => conn.provider === connectionKey)
|
||||
|
||||
const accountSummary = {
|
||||
id: acc.id,
|
||||
name: displayName,
|
||||
}
|
||||
|
||||
if (existingConnection) {
|
||||
// Add account to existing connection
|
||||
existingConnection.accounts = existingConnection.accounts || []
|
||||
existingConnection.accounts.push(accountSummary)
|
||||
|
||||
existingConnection.scopes = Array.from(
|
||||
new Set([...(existingConnection.scopes || []), ...scopes])
|
||||
)
|
||||
|
||||
const existingTimestamp = existingConnection.lastConnected
|
||||
? new Date(existingConnection.lastConnected).getTime()
|
||||
: 0
|
||||
const candidateTimestamp = acc.updatedAt.getTime()
|
||||
|
||||
if (candidateTimestamp > existingTimestamp) {
|
||||
existingConnection.lastConnected = acc.updatedAt.toISOString()
|
||||
}
|
||||
} else {
|
||||
// Create new connection
|
||||
connections.push({
|
||||
provider: connectionKey,
|
||||
baseProvider,
|
||||
featureType,
|
||||
isConnected: true,
|
||||
scopes,
|
||||
lastConnected: acc.updatedAt.toISOString(),
|
||||
accounts: [accountSummary],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ connections }, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error fetching OAuth connections`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
export const GET = defineInternalJsonRoute({
|
||||
contract: listOAuthConnectionsContract,
|
||||
auth: internalSessionAuth,
|
||||
operation: credentialUserOperations.listOAuthConnections,
|
||||
rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }),
|
||||
errorPolicy: internalCredentialErrorPolicy,
|
||||
mapInput: () => ({}),
|
||||
useCase: listOAuthConnectionsUseCase,
|
||||
})
|
||||
|
||||
@@ -4,7 +4,13 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { hybridAuthMockFns, permissionsMock, workflowsUtilsMock } from '@sim/testing'
|
||||
import {
|
||||
dbChainMockFns,
|
||||
hybridAuthMockFns,
|
||||
permissionsMock,
|
||||
resetDbChainMock,
|
||||
workflowsUtilsMock,
|
||||
} from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -26,6 +32,7 @@ describe('OAuth Credentials API Route', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
it('should handle unauthenticated user', async () => {
|
||||
@@ -90,4 +97,33 @@ describe('OAuth Credentials API Route', () => {
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.credentials).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not expose a managed credential requested by exact ID', async () => {
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'session',
|
||||
})
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'managed-credential-1',
|
||||
workspaceId: 'workspace-1',
|
||||
type: 'managed_oauth',
|
||||
displayName: 'Managed Gmail',
|
||||
providerId: 'google-email',
|
||||
accountId: null,
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
accountProviderId: null,
|
||||
accountScope: null,
|
||||
accountUpdatedAt: null,
|
||||
},
|
||||
])
|
||||
|
||||
const response = await GET(
|
||||
createMockRequestWithQuery('GET', '?credentialId=managed-credential-1')
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toEqual({ credentials: [] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,7 @@ describe('OAuth Disconnect API Route', () => {
|
||||
it('should disconnect provider successfully', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValueOnce({
|
||||
user: { id: 'user-123' },
|
||||
session: { id: 'session-1' },
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
@@ -42,6 +43,7 @@ describe('OAuth Disconnect API Route', () => {
|
||||
it('should disconnect specific provider ID successfully', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValueOnce({
|
||||
user: { id: 'user-123' },
|
||||
session: { id: 'session-1' },
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
@@ -67,12 +69,13 @@ describe('OAuth Disconnect API Route', () => {
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(data.error).toBe('User not authenticated')
|
||||
expect(data.error).toBe('Unauthorized')
|
||||
})
|
||||
|
||||
it('should handle missing provider', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValueOnce({
|
||||
user: { id: 'user-123' },
|
||||
session: { id: 'session-1' },
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {})
|
||||
@@ -87,6 +90,7 @@ describe('OAuth Disconnect API Route', () => {
|
||||
it('should handle database error', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValueOnce({
|
||||
user: { id: 'user-123' },
|
||||
session: { id: 'session-1' },
|
||||
})
|
||||
|
||||
dbChainMockFns.where.mockRejectedValueOnce(new Error('Database error'))
|
||||
|
||||
@@ -1,132 +1,26 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { account, credential } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray, like, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { disconnectOAuthContract } from '@/lib/api/contracts/oauth-connections'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { deleteCredential } from '@/lib/credentials/deletion'
|
||||
import { providerIdsForService } from '@/lib/oauth/utils'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import {
|
||||
defineInternalJsonRoute,
|
||||
internalRateLimits,
|
||||
internalSessionAuth,
|
||||
} from '@/lib/api/server/routes'
|
||||
import {
|
||||
credentialValidationParseOptions,
|
||||
internalCredentialErrorPolicy,
|
||||
} from '@/lib/credentials/api/route-policies'
|
||||
import { disconnectOAuthUseCase } from '@/lib/credentials/application/oauth-accounts'
|
||||
import { credentialUserOperations } from '@/lib/credentials/application/operations'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('OAuthDisconnectAPI')
|
||||
|
||||
/**
|
||||
* Disconnect an OAuth provider for the current user
|
||||
*/
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthenticated disconnect request rejected`)
|
||||
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
disconnectOAuthContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => {
|
||||
logger.warn(`[${requestId}] Invalid disconnect request`, { errors: error.issues })
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(error, 'Validation failed') },
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { provider, providerId, accountId } = parsed.data.body
|
||||
|
||||
logger.info(`[${requestId}] Processing OAuth disconnect request`, {
|
||||
provider,
|
||||
hasProviderId: !!providerId,
|
||||
})
|
||||
|
||||
// Delete credentials before their accounts so deleteCredential can clear
|
||||
// stored references first. Otherwise FK CASCADE would orphan them silently.
|
||||
const accountFilter = accountId
|
||||
? and(eq(account.userId, session.user.id), eq(account.id, accountId))
|
||||
: providerId
|
||||
? and(eq(account.userId, session.user.id), eq(account.providerId, providerId))
|
||||
: and(
|
||||
eq(account.userId, session.user.id),
|
||||
or(
|
||||
// The prefix sweep already caught `{base}-{feature}` ids by
|
||||
// accident; an alternate authorization server shares that shape,
|
||||
// so name it explicitly rather than relying on the accident.
|
||||
inArray(account.providerId, providerIdsForService(provider)),
|
||||
like(account.providerId, `${provider}-%`)
|
||||
)
|
||||
)
|
||||
|
||||
const targetAccounts = await db.select({ id: account.id }).from(account).where(accountFilter)
|
||||
|
||||
const targetAccountIds = targetAccounts.map((a) => a.id)
|
||||
|
||||
if (targetAccountIds.length > 0) {
|
||||
const credentialsToDelete = await db
|
||||
.select({
|
||||
id: credential.id,
|
||||
workspaceId: credential.workspaceId,
|
||||
providerId: credential.providerId,
|
||||
})
|
||||
.from(credential)
|
||||
.where(inArray(credential.accountId, targetAccountIds))
|
||||
|
||||
for (const cred of credentialsToDelete) {
|
||||
await deleteCredential({
|
||||
credentialId: cred.id,
|
||||
actorId: session.user.id,
|
||||
actorName: session.user.name,
|
||||
actorEmail: session.user.email,
|
||||
reason: 'oauth_disconnect',
|
||||
request,
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'credential_deleted',
|
||||
{
|
||||
credential_type: 'oauth',
|
||||
provider_id: cred.providerId ?? providerId ?? provider,
|
||||
workspace_id: cred.workspaceId,
|
||||
},
|
||||
{ groups: { workspace: cred.workspaceId } }
|
||||
)
|
||||
}
|
||||
|
||||
await db.delete(account).where(inArray(account.id, targetAccountIds))
|
||||
}
|
||||
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.OAUTH_DISCONNECTED,
|
||||
resourceType: AuditResourceType.OAUTH,
|
||||
resourceId: providerId ?? provider,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: provider,
|
||||
description: `Disconnected OAuth provider: ${provider}`,
|
||||
metadata: { provider, providerId },
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error disconnecting OAuth provider`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
export const POST = defineInternalJsonRoute({
|
||||
contract: disconnectOAuthContract,
|
||||
auth: internalSessionAuth,
|
||||
operation: credentialUserOperations.disconnectOAuth,
|
||||
rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }),
|
||||
errorPolicy: internalCredentialErrorPolicy,
|
||||
parseOptions: credentialValidationParseOptions,
|
||||
mapInput: ({ body }) => body,
|
||||
useCase: disconnectOAuthUseCase,
|
||||
present: () => ({ success: true as const }),
|
||||
})
|
||||
|
||||
@@ -12,8 +12,17 @@ import {
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockAuthorizeCredentialUse, mockResolveServiceAccountToken } = vi.hoisted(() => ({
|
||||
const {
|
||||
mockAuthenticateManagedOAuthDelegation,
|
||||
mockAuthorizeCredentialUse,
|
||||
mockGetToolMetadata,
|
||||
mockResolveManagedOAuthCredentialToken,
|
||||
mockResolveServiceAccountToken,
|
||||
} = vi.hoisted(() => ({
|
||||
mockAuthenticateManagedOAuthDelegation: vi.fn(),
|
||||
mockAuthorizeCredentialUse: vi.fn(),
|
||||
mockGetToolMetadata: vi.fn(),
|
||||
mockResolveManagedOAuthCredentialToken: vi.fn(),
|
||||
mockResolveServiceAccountToken: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -24,8 +33,20 @@ vi.mock('@/lib/oauth/credential-service', () => ({
|
||||
|
||||
vi.mock('@/lib/auth/credential-access', () => ({
|
||||
authorizeCredentialUse: mockAuthorizeCredentialUse,
|
||||
authorizeCredentialUseForAuth: mockAuthorizeCredentialUse,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credentials/application/managed-oauth-delegation', () => ({
|
||||
authenticateManagedOAuthDelegation: mockAuthenticateManagedOAuthDelegation,
|
||||
InvalidManagedOAuthDelegationError: class InvalidManagedOAuthDelegationError extends Error {},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credentials/application/resolve-managed-oauth-token', () => ({
|
||||
resolveManagedOAuthCredentialToken: { execute: mockResolveManagedOAuthCredentialToken },
|
||||
}))
|
||||
|
||||
vi.mock('@/tools/metadata', () => ({ getToolMetadata: mockGetToolMetadata }))
|
||||
|
||||
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
|
||||
import { GET, POST } from '@/app/api/auth/oauth/token/route'
|
||||
|
||||
@@ -107,6 +128,38 @@ describe('OAuth Token API Routes', () => {
|
||||
expect(authOAuthUtilsMockFns.mockGetCredential).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not authenticate managed delegation for an ordinary OAuth credential', async () => {
|
||||
mockAuthorizeCredentialUse.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
authType: 'internal_jwt',
|
||||
requesterUserId: 'workflow-owner-id',
|
||||
credentialOwnerUserId: 'workflow-owner-id',
|
||||
})
|
||||
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
|
||||
id: 'credential-id',
|
||||
accessToken: 'test-token',
|
||||
refreshToken: 'refresh-token',
|
||||
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
|
||||
providerId: 'google',
|
||||
})
|
||||
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValueOnce({
|
||||
accessToken: 'fresh-token',
|
||||
refreshed: false,
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{ credentialId: 'credential-id', workflowId: 'workflow-id' },
|
||||
{ 'x-sim-managed-oauth-delegation': 'Bearer stale-delegation' }
|
||||
)
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toMatchObject({ accessToken: 'fresh-token' })
|
||||
expect(mockAuthenticateManagedOAuthDelegation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle missing credentialId', async () => {
|
||||
const req = createMockRequest('POST', {})
|
||||
|
||||
@@ -201,6 +254,36 @@ describe('OAuth Token API Routes', () => {
|
||||
})
|
||||
|
||||
describe('service account path', () => {
|
||||
it('threads the NetSuite SuiteTalk instance URL into the token response', async () => {
|
||||
const instanceUrl = 'https://1234567.suitetalk.api.netsuite.com'
|
||||
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({
|
||||
accountId: '',
|
||||
credentialId: 'netsuite-credential-id',
|
||||
credentialType: 'service_account',
|
||||
providerId: 'netsuite-service-account',
|
||||
workspaceId: 'workspace-id',
|
||||
usedCredentialTable: true,
|
||||
})
|
||||
mockAuthorizeCredentialUse.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
authType: 'session',
|
||||
requesterUserId: 'test-user-id',
|
||||
workspaceId: 'workspace-id',
|
||||
})
|
||||
mockResolveServiceAccountToken.mockResolvedValueOnce({
|
||||
accessToken: 'netsuite-token',
|
||||
instanceUrl,
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('POST', { credentialId: 'netsuite-credential-id' })
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toMatchObject({ accessToken: 'netsuite-token', instanceUrl })
|
||||
})
|
||||
|
||||
it('should thread authStyle from the resolver into the response', async () => {
|
||||
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({
|
||||
accountId: '',
|
||||
@@ -301,6 +384,140 @@ describe('OAuth Token API Routes', () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe('managed OAuth path', () => {
|
||||
const managedCredential = {
|
||||
accountId: '',
|
||||
credentialId: 'managed-credential-id',
|
||||
credentialType: 'managed_oauth',
|
||||
providerId: 'google-email',
|
||||
workspaceId: 'workspace-id',
|
||||
usedCredentialTable: true,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce(managedCredential)
|
||||
mockGetToolMetadata.mockReturnValue({
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'google-email',
|
||||
requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when workflow delegation is missing', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest('POST', {
|
||||
credentialId: 'managed-credential-id',
|
||||
toolId: 'gmail_read',
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
code: 'MANAGED_CREDENTIAL_DELEGATION_REQUIRED',
|
||||
})
|
||||
expect(mockResolveManagedOAuthCredentialToken).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves a manually supplied managed credential ID with scoped delegation', async () => {
|
||||
const principal = {
|
||||
kind: 'delegated' as const,
|
||||
serviceId: 'executor' as const,
|
||||
subjectUserId: 'user-id',
|
||||
workspaceId: 'workspace-id',
|
||||
delegationId: 'delegation-id',
|
||||
audience: 'sim:managed-oauth-credentials',
|
||||
issuedAt: new Date(Date.now() - 1_000),
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
resourceScope: { credentialId: 'managed-credential-id' },
|
||||
delegationContext: {
|
||||
kind: 'workflow_execution' as const,
|
||||
workflowId: 'workflow-id',
|
||||
},
|
||||
}
|
||||
mockAuthenticateManagedOAuthDelegation.mockResolvedValueOnce(principal)
|
||||
mockResolveManagedOAuthCredentialToken.mockResolvedValueOnce({
|
||||
accessToken: 'managed-access-token',
|
||||
refreshed: false,
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{ credentialId: 'managed-credential-id', toolId: 'gmail_read' },
|
||||
{ 'x-sim-managed-oauth-delegation': 'Bearer delegated-token' }
|
||||
)
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toEqual({ accessToken: 'managed-access-token' })
|
||||
expect(mockResolveManagedOAuthCredentialToken).toHaveBeenCalledWith({
|
||||
principal,
|
||||
input: {
|
||||
credentialId: 'managed-credential-id',
|
||||
expectedProviderId: 'google-email',
|
||||
requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'],
|
||||
toolId: 'gmail_read',
|
||||
},
|
||||
request: expect.any(NextRequest),
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the trusted provider scope policy when a Slack tool omits narrower scopes', async () => {
|
||||
mockGetToolMetadata.mockReturnValueOnce({
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'slack',
|
||||
},
|
||||
})
|
||||
const principal = {
|
||||
kind: 'delegated' as const,
|
||||
serviceId: 'executor' as const,
|
||||
subjectUserId: 'user-id',
|
||||
workspaceId: 'workspace-id',
|
||||
delegationId: 'delegation-id',
|
||||
audience: 'sim:managed-oauth-credentials',
|
||||
issuedAt: new Date(Date.now() - 1_000),
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
resourceScope: { credentialId: 'managed-credential-id' },
|
||||
delegationContext: {
|
||||
kind: 'workflow_execution' as const,
|
||||
workflowId: 'workflow-id',
|
||||
},
|
||||
}
|
||||
mockAuthenticateManagedOAuthDelegation.mockResolvedValueOnce(principal)
|
||||
mockResolveManagedOAuthCredentialToken.mockResolvedValueOnce({
|
||||
accessToken: 'managed-slack-token',
|
||||
refreshed: false,
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{ credentialId: 'managed-credential-id', toolId: 'slack_message' },
|
||||
{ 'x-sim-managed-oauth-delegation': 'Bearer delegated-token' }
|
||||
)
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockResolveManagedOAuthCredentialToken).toHaveBeenCalledWith({
|
||||
principal,
|
||||
input: {
|
||||
credentialId: 'managed-credential-id',
|
||||
expectedProviderId: 'slack',
|
||||
requiredScopes: expect.arrayContaining([
|
||||
'channels:read',
|
||||
'channels:history',
|
||||
'chat:write',
|
||||
]),
|
||||
toolId: 'slack_message',
|
||||
},
|
||||
request: expect.any(NextRequest),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('credentialAccountUserId + providerId path', () => {
|
||||
it('should reject unauthenticated requests', async () => {
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
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 {
|
||||
MANAGED_OAUTH_DELEGATION_HEADER,
|
||||
oauthTokenGetContract,
|
||||
oauthTokenPostContract,
|
||||
} from '@/lib/api/contracts/oauth-connections'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
|
||||
import {
|
||||
getCredential,
|
||||
getOAuthToken,
|
||||
refreshTokenIfNeeded,
|
||||
resolveOAuthAccountId,
|
||||
resolveServiceAccountToken,
|
||||
} from '@/lib/oauth/credential-service'
|
||||
import { extractSalesforceInstanceUrl, isSalesforceOAuthProviderId } from '@/lib/oauth/salesforce'
|
||||
authenticateManagedOAuthDelegation,
|
||||
InvalidManagedOAuthDelegationError,
|
||||
} from '@/lib/credentials/application/managed-oauth-delegation'
|
||||
import { resolveManagedOAuthCredentialToken } from '@/lib/credentials/application/resolve-managed-oauth-token'
|
||||
import { ManagedOAuthCredentialError } from '@/lib/credentials/managed-oauth'
|
||||
import { getCredential, getOAuthToken, resolveOAuthAccountId } from '@/lib/oauth/credential-service'
|
||||
import { completeOAuthCredentialToken, resolveCredentialToken } from '@/lib/oauth/token-resolution'
|
||||
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { extractZohoDeskBaseFromScope } from '@/tools/zoho_desk/host-allowlist'
|
||||
import { getToolMetadata } from '@/tools/metadata'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -58,6 +61,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
credentialId,
|
||||
credentialAccountUserId,
|
||||
providerId,
|
||||
toolId,
|
||||
workflowId,
|
||||
scopes,
|
||||
impersonateEmail,
|
||||
@@ -123,194 +127,149 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!credentialId) {
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (resolved?.credentialType === 'service_account' && resolved.credentialId) {
|
||||
const authz = await authorizeCredentialUse(request, {
|
||||
credentialId,
|
||||
workflowId: workflowId ?? undefined,
|
||||
requireWorkflowIdForInternal: false,
|
||||
callerUserId,
|
||||
})
|
||||
if (!authz.ok) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
const resolved = credentialId ? await resolveOAuthAccountId(credentialId) : null
|
||||
if (resolved?.credentialType === 'managed_oauth' && resolved.credentialId) {
|
||||
const managedOAuthDelegation = parsed.data.headers?.[MANAGED_OAUTH_DELEGATION_HEADER]
|
||||
if (!managedOAuthDelegation) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 'MANAGED_CREDENTIAL_DELEGATION_REQUIRED',
|
||||
error: 'Managed credentials can only be used by an authenticated workflow execution',
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const saActorId = authz.requesterUserId
|
||||
const saWorkspaceId = resolved.workspaceId ?? authz.workspaceId ?? null
|
||||
const emitServiceAccountAccess = () => {
|
||||
if (!saActorId) return
|
||||
recordAudit({
|
||||
workspaceId: saWorkspaceId,
|
||||
actorId: saActorId,
|
||||
action: AuditAction.CREDENTIAL_ACCESSED,
|
||||
resourceType: AuditResourceType.CREDENTIAL,
|
||||
resourceId: resolved.credentialId ?? credentialId,
|
||||
description: `Accessed service account credential for provider ${resolved.providerId ?? 'unknown'}`,
|
||||
metadata: {
|
||||
provider: resolved.providerId,
|
||||
credentialType: 'service_account',
|
||||
},
|
||||
request,
|
||||
})
|
||||
captureServerEvent(
|
||||
saActorId,
|
||||
'credential_used',
|
||||
let managedOAuthPrincipal: WorkflowExecutionDelegatedPrincipal
|
||||
try {
|
||||
managedOAuthPrincipal = await authenticateManagedOAuthDelegation(
|
||||
managedOAuthDelegation,
|
||||
resolved.credentialId
|
||||
)
|
||||
} catch (error) {
|
||||
if (!(error instanceof InvalidManagedOAuthDelegationError)) throw error
|
||||
return NextResponse.json(
|
||||
{
|
||||
credential_type: 'service_account',
|
||||
provider_id: resolved.providerId ?? 'unknown',
|
||||
...(saWorkspaceId ? { workspace_id: saWorkspaceId } : {}),
|
||||
code: 'MANAGED_CREDENTIAL_DELEGATION_INVALID',
|
||||
error: error.message,
|
||||
},
|
||||
saWorkspaceId ? { groups: { workspace: saWorkspaceId } } : undefined
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
if (!toolId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 'MANAGED_CREDENTIAL_TOOL_REQUIRED',
|
||||
error: 'A tool ID is required to use a managed credential',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const toolMetadata = getToolMetadata(toolId)
|
||||
if (!toolMetadata?.oauth?.required) {
|
||||
logger.error(`[${requestId}] Tool is not configured for managed OAuth`, { toolId })
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 'MANAGED_CREDENTIAL_TOOL_UNSUPPORTED',
|
||||
error: 'This tool is not configured to use managed credentials',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
const requiredScopes =
|
||||
toolMetadata.oauth.requiredScopes ??
|
||||
getCanonicalScopesForProvider(toolMetadata.oauth.provider)
|
||||
if (requiredScopes.length === 0) {
|
||||
logger.error(`[${requestId}] Tool has no trusted OAuth scope policy`, {
|
||||
toolId,
|
||||
providerId: toolMetadata.oauth.provider,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 'MANAGED_CREDENTIAL_TOOL_UNSUPPORTED',
|
||||
error: 'This tool is not configured to use managed credentials',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await resolveServiceAccountToken(
|
||||
resolved.credentialId,
|
||||
resolved.providerId,
|
||||
scopes ?? [],
|
||||
impersonateEmail
|
||||
const result = await resolveManagedOAuthCredentialToken.execute({
|
||||
principal: managedOAuthPrincipal,
|
||||
input: {
|
||||
credentialId: resolved.credentialId,
|
||||
expectedProviderId: toolMetadata.oauth.provider,
|
||||
requiredScopes,
|
||||
toolId,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
managedOAuthPrincipal.subjectUserId,
|
||||
'credential_used',
|
||||
{
|
||||
credential_type: 'managed_oauth',
|
||||
provider_id: toolMetadata.oauth.provider,
|
||||
workspace_id: managedOAuthPrincipal.workspaceId,
|
||||
},
|
||||
{ groups: { workspace: managedOAuthPrincipal.workspaceId } }
|
||||
)
|
||||
emitServiceAccountAccess()
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
accessToken: result.accessToken,
|
||||
cloudId: result.cloudId,
|
||||
domain: result.domain,
|
||||
instanceUrl: result.instanceUrl,
|
||||
apiDomain: result.apiDomain,
|
||||
authStyle: result.authStyle,
|
||||
...(result.idToken ? { idToken: result.idToken } : {}),
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Service account token error:`, error)
|
||||
if (error instanceof TokenServiceAccountValidationError) {
|
||||
// Classified provider outages are infra failures, not bad credentials.
|
||||
if (error.code === 'provider_unavailable') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential provider is temporarily unavailable' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
// A stored host that no longer resolves is a configuration failure —
|
||||
// surface the code so runtime consumers can say "check the host"
|
||||
// instead of a generic auth error.
|
||||
if (error.code === 'site_not_found') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: error.code,
|
||||
error: 'Credential host not found — reconnect the credential with a valid host',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
// A revoked/rotated-away or misconfigured stored secret — surface the
|
||||
// code so runtime consumers can prompt to reconnect the credential
|
||||
// rather than showing a generic auth failure.
|
||||
if (error.code === 'invalid_credentials') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: error.code,
|
||||
error: 'Credential rejected by the provider — reconnect the credential',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
if (error instanceof ManagedOAuthCredentialError) {
|
||||
logger.warn(`[${requestId}] Managed OAuth credential rejected`, {
|
||||
credentialId: resolved.credentialId,
|
||||
code: error.code,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ code: error.code, error: error.message },
|
||||
{ status: error.statusCode }
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: 'Failed to get service account token' }, { status: 401 })
|
||||
|
||||
const orchestrationError = asOrchestrationError(error)
|
||||
if (orchestrationError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 'MANAGED_CREDENTIAL_UNAUTHORIZED',
|
||||
error: orchestrationError.message,
|
||||
},
|
||||
{ status: statusForOrchestrationError(orchestrationError.code) }
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request, {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
const result = await resolveCredentialToken(auth, {
|
||||
requestId,
|
||||
credentialId,
|
||||
workflowId: workflowId ?? undefined,
|
||||
requireWorkflowIdForInternal: false,
|
||||
scopes,
|
||||
impersonateEmail,
|
||||
callerUserId,
|
||||
auditRequest: request,
|
||||
resolvedCredential: resolved,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const resolvedCredentialId = authz.resolvedCredentialId || credentialId
|
||||
const credential = await getCredential(
|
||||
requestId,
|
||||
resolvedCredentialId,
|
||||
authz.credentialOwnerUserId
|
||||
)
|
||||
|
||||
if (!credential) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const oauthActorId = authz.requesterUserId
|
||||
const oauthWorkspaceId = authz.workspaceId ?? null
|
||||
|
||||
try {
|
||||
const { accessToken } = await refreshTokenIfNeeded(
|
||||
requestId,
|
||||
credential,
|
||||
resolvedCredentialId
|
||||
)
|
||||
|
||||
if (oauthActorId) {
|
||||
recordAudit({
|
||||
workspaceId: oauthWorkspaceId,
|
||||
actorId: oauthActorId,
|
||||
action: AuditAction.CREDENTIAL_ACCESSED,
|
||||
resourceType: AuditResourceType.CREDENTIAL,
|
||||
resourceId: resolvedCredentialId,
|
||||
description: `Accessed OAuth credential for provider ${credential.providerId}`,
|
||||
metadata: {
|
||||
provider: credential.providerId,
|
||||
credentialType: 'oauth',
|
||||
},
|
||||
request,
|
||||
})
|
||||
captureServerEvent(
|
||||
oauthActorId,
|
||||
'credential_used',
|
||||
{
|
||||
credential_type: 'oauth',
|
||||
provider_id: credential.providerId,
|
||||
...(oauthWorkspaceId ? { workspace_id: oauthWorkspaceId } : {}),
|
||||
},
|
||||
oauthWorkspaceId ? { groups: { workspace: oauthWorkspaceId } } : undefined
|
||||
)
|
||||
}
|
||||
|
||||
const instanceUrl = isSalesforceOAuthProviderId(credential.providerId)
|
||||
? extractSalesforceInstanceUrl(credential.scope)
|
||||
: undefined
|
||||
|
||||
// Zoho Desk persists its data-center-specific REST base URL in the scope
|
||||
// string (derived from the token response api_domain) so callers never
|
||||
// assume a host. Surface it as apiDomain for tool param injection.
|
||||
let apiDomain: string | undefined
|
||||
if (credential.providerId === 'zoho-desk' && credential.scope) {
|
||||
// Use the shared extractor, not a local regex: it also enforces https +
|
||||
// the Zoho apex allowlist. This value is injected into EVERY tool call,
|
||||
// so an unvalidated host here would receive the OAuth token.
|
||||
apiDomain = extractZohoDeskBaseFromScope(credential.scope)
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
accessToken,
|
||||
idToken: credential.idToken || undefined,
|
||||
...(instanceUrl && { instanceUrl }),
|
||||
...(apiDomain && { apiDomain }),
|
||||
},
|
||||
{ status: 200 }
|
||||
{ ...(result.code ? { code: result.code } : {}), error: result.error },
|
||||
{ status: result.status }
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to refresh access token:`, error)
|
||||
return NextResponse.json({ error: 'Failed to refresh access token' }, { status: 401 })
|
||||
}
|
||||
|
||||
return NextResponse.json(result.token, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error getting access token`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
@@ -366,70 +325,20 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
return NextResponse.json({ error: 'No access token available' }, { status: 400 })
|
||||
}
|
||||
|
||||
const actorId = authz.requesterUserId
|
||||
const workspaceId = authz.workspaceId ?? null
|
||||
const result = await completeOAuthCredentialToken({
|
||||
requestId,
|
||||
credential,
|
||||
resolvedCredentialId,
|
||||
actorId: authz.requesterUserId,
|
||||
workspaceId: authz.workspaceId ?? null,
|
||||
auditRequest: request,
|
||||
})
|
||||
|
||||
try {
|
||||
const { accessToken } = await refreshTokenIfNeeded(
|
||||
requestId,
|
||||
credential,
|
||||
resolvedCredentialId
|
||||
)
|
||||
|
||||
if (actorId) {
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId,
|
||||
action: AuditAction.CREDENTIAL_ACCESSED,
|
||||
resourceType: AuditResourceType.CREDENTIAL,
|
||||
resourceId: resolvedCredentialId,
|
||||
description: `Accessed OAuth credential for provider ${credential.providerId}`,
|
||||
metadata: {
|
||||
provider: credential.providerId,
|
||||
credentialType: 'oauth',
|
||||
},
|
||||
request,
|
||||
})
|
||||
captureServerEvent(
|
||||
actorId,
|
||||
'credential_used',
|
||||
{
|
||||
credential_type: 'oauth',
|
||||
provider_id: credential.providerId,
|
||||
...(workspaceId ? { workspace_id: workspaceId } : {}),
|
||||
},
|
||||
workspaceId ? { groups: { workspace: workspaceId } } : undefined
|
||||
)
|
||||
}
|
||||
|
||||
const instanceUrl = isSalesforceOAuthProviderId(credential.providerId)
|
||||
? extractSalesforceInstanceUrl(credential.scope)
|
||||
: undefined
|
||||
|
||||
// Zoho Desk persists its data-center-specific REST base URL in the scope
|
||||
// string (derived from the token response api_domain) so callers never
|
||||
// assume a host. Surface it as apiDomain for tool param injection.
|
||||
let apiDomain: string | undefined
|
||||
if (credential.providerId === 'zoho-desk' && credential.scope) {
|
||||
// Use the shared extractor, not a local regex: it also enforces https +
|
||||
// the Zoho apex allowlist. This value is injected into EVERY tool call,
|
||||
// so an unvalidated host here would receive the OAuth token.
|
||||
apiDomain = extractZohoDeskBaseFromScope(credential.scope)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
accessToken,
|
||||
idToken: credential.idToken || undefined,
|
||||
...(instanceUrl && { instanceUrl }),
|
||||
...(apiDomain && { apiDomain }),
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to refresh access token:`, error)
|
||||
return NextResponse.json({ error: 'Failed to refresh access token' }, { status: 401 })
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ error: result.error }, { status: result.status })
|
||||
}
|
||||
|
||||
return NextResponse.json(result.token, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error fetching access token`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
|
||||
@@ -26,7 +26,10 @@ vi.mock('@/lib/credentials/client-credential-accounts/server', () => ({
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { __resetCoalesceLocallyForTests } from '@/lib/concurrency/singleflight'
|
||||
import { ZOOM_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/client-credential-accounts/descriptors'
|
||||
import {
|
||||
NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
ZOOM_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
} from '@/lib/credentials/client-credential-accounts/descriptors'
|
||||
import { refreshOAuthToken } from '@/lib/oauth'
|
||||
import {
|
||||
getCredential,
|
||||
@@ -526,6 +529,33 @@ describe('OAuth Utils', () => {
|
||||
expect(mockMinter).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('forwards NetSuite certificate material and caches its SuiteTalk instance URL', async () => {
|
||||
const credId = 'ccsa-netsuite-certificate'
|
||||
const fields = {
|
||||
clientId: 'netsuite-client',
|
||||
certificateId: 'certificate-id',
|
||||
orgId: 'https://1234567.suitetalk.api.netsuite.com',
|
||||
privateKey: 'private-key',
|
||||
}
|
||||
mockDecryptSecret.mockResolvedValueOnce({ decrypted: JSON.stringify(fields) })
|
||||
mockCredentialRow(ENCRYPTED_KEY_A)
|
||||
mockMinter.mockResolvedValueOnce({
|
||||
accessToken: 'netsuite-token',
|
||||
expiresInSeconds: 3600,
|
||||
instanceUrl: fields.orgId,
|
||||
})
|
||||
|
||||
const first = await resolveServiceAccountToken(credId, NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID)
|
||||
|
||||
expect(first).toEqual({ accessToken: 'netsuite-token', instanceUrl: fields.orgId })
|
||||
expect(mockMinter).toHaveBeenCalledWith(fields, { skipIdentity: true })
|
||||
|
||||
mockCredentialRow(ENCRYPTED_KEY_A)
|
||||
const cached = await resolveServiceAccountToken(credId, NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID)
|
||||
expect(cached).toEqual(first)
|
||||
expect(mockMinter).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('re-mints when remaining validity is below the 5-minute serve floor', async () => {
|
||||
const credId = 'ccsa-ttl-floor'
|
||||
mockCredentialRow(ENCRYPTED_KEY_A)
|
||||
|
||||
@@ -1,360 +1,247 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
createMockRequest,
|
||||
dbChainMockFns,
|
||||
resetDbChainMock,
|
||||
resetEnvMock,
|
||||
setEnv,
|
||||
} from '@sim/testing'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMockRequest } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ForbiddenOperationError } from '@/lib/core/application/forbidden'
|
||||
import { InsufficientWorkspacePermissionsError } from '@/lib/core/application/workspace-authorization'
|
||||
import { OrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import { CredentialConnectionProviderMismatchError } from '@/lib/credentials/application/connection-target'
|
||||
|
||||
const {
|
||||
mockGetSession,
|
||||
mockOAuth2LinkAccount,
|
||||
mockCheckWorkspaceAccess,
|
||||
mockGetCredentialActorContext,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetSession: vi.fn(),
|
||||
mockOAuth2LinkAccount: vi.fn(),
|
||||
mockCheckWorkspaceAccess: vi.fn(),
|
||||
mockGetCredentialActorContext: vi.fn(),
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getSession: vi.fn(),
|
||||
linkAccount: vi.fn(),
|
||||
getBaseUrl: vi.fn(),
|
||||
requireClient: vi.fn(),
|
||||
createConnection: vi.fn(),
|
||||
launchConnection: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/auth', () => ({
|
||||
auth: { api: { oAuth2LinkAccount: mockOAuth2LinkAccount } },
|
||||
getSession: mockGetSession,
|
||||
getSession: mocks.getSession,
|
||||
auth: { api: { oAuth2LinkAccount: mocks.linkAccount } },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
checkWorkspaceAccess: mockCheckWorkspaceAccess,
|
||||
vi.mock('@/lib/core/utils/urls', () => ({
|
||||
SITE_URL: 'https://www.sim.ai',
|
||||
getBaseUrl: mocks.getBaseUrl,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credentials/access', () => ({
|
||||
getCredentialActorContext: mockGetCredentialActorContext,
|
||||
vi.mock('@/lib/core/config/env-capabilities.server', () => ({
|
||||
requireConfiguredOAuthClient: mocks.requireClient,
|
||||
wireServerFallback: () => ({
|
||||
configured: false,
|
||||
providerIds: [],
|
||||
providers: [],
|
||||
execute: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/oauth/utils', () => ({
|
||||
getAllOAuthServices: vi.fn(() => [{ providerId: 'google-email', name: 'Gmail' }]),
|
||||
// Real implementation: a credential id matches its service's OAuth id, an
|
||||
// alternate authorization server, or the family's service-account id.
|
||||
credentialProviderMatchesService: (
|
||||
credentialProviderId: string,
|
||||
service: {
|
||||
providerId: string
|
||||
serviceAccountProviderId?: string
|
||||
additionalProviderIds?: readonly string[]
|
||||
}
|
||||
) =>
|
||||
service.providerId === credentialProviderId ||
|
||||
service.serviceAccountProviderId === credentialProviderId ||
|
||||
(service.additionalProviderIds?.includes(credentialProviderId) ?? false),
|
||||
vi.mock('@/lib/credentials/application/create-credential-connection', () => ({
|
||||
createCredentialConnection: {
|
||||
operation: { id: 'credentials.connections.create' },
|
||||
execute: mocks.createConnection,
|
||||
},
|
||||
}))
|
||||
vi.mock('@/lib/credentials/application/launch-credential-connection', () => ({
|
||||
launchCredentialConnection: {
|
||||
operation: { id: 'credentials.connections.launch' },
|
||||
execute: mocks.launchConnection,
|
||||
},
|
||||
}))
|
||||
|
||||
import { GET } from '@/app/api/auth/oauth2/authorize/route'
|
||||
|
||||
const BASE_URL = 'https://sim.test'
|
||||
const WORKSPACE_ID = 'ws-1'
|
||||
const USER_ID = 'user-1'
|
||||
const CREDENTIAL_ID = 'cred-1'
|
||||
const LINK_URL = 'https://provider.example/authorize?state=abc'
|
||||
const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555'
|
||||
|
||||
function authorizeRequest(query: Record<string, string>) {
|
||||
const url = new URL(`${BASE_URL}/api/auth/oauth2/authorize`)
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
url.searchParams.set(key, value)
|
||||
}
|
||||
function request(query: Record<string, string>) {
|
||||
const url = new URL('/api/auth/oauth2/authorize', BASE_URL)
|
||||
for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value)
|
||||
return createMockRequest('GET', undefined, {}, url.toString())
|
||||
}
|
||||
|
||||
function oauthCredentialActor(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
credential: {
|
||||
id: CREDENTIAL_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
type: 'oauth',
|
||||
providerId: 'google-email',
|
||||
displayName: 'Work Gmail',
|
||||
...((overrides.credential as Record<string, unknown>) ?? {}),
|
||||
},
|
||||
member: null,
|
||||
hasWorkspaceAccess: true,
|
||||
canWriteWorkspace: true,
|
||||
isAdmin: true,
|
||||
...Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== 'credential')),
|
||||
}
|
||||
function linkResponse(url = 'https://provider.example/authorize') {
|
||||
return new Response(JSON.stringify({ url }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('OAuth2 authorize route', () => {
|
||||
afterAll(() => {
|
||||
resetEnvMock()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
setEnv({
|
||||
NEXT_PUBLIC_APP_URL: BASE_URL,
|
||||
GOOGLE_CLIENT_ID: 'google-client',
|
||||
GOOGLE_CLIENT_SECRET: 'google-secret',
|
||||
mocks.getBaseUrl.mockReturnValue(BASE_URL)
|
||||
mocks.getSession.mockResolvedValue({
|
||||
user: { id: 'user-1' },
|
||||
session: { id: 'session-1' },
|
||||
})
|
||||
mockGetSession.mockResolvedValue({ user: { id: USER_ID } })
|
||||
mockCheckWorkspaceAccess.mockResolvedValue({
|
||||
hasAccess: true,
|
||||
canWrite: true,
|
||||
canAdmin: false,
|
||||
workspace: { id: WORKSPACE_ID },
|
||||
mocks.createConnection.mockResolvedValue({
|
||||
providerId: 'google-email',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
draftId: 'draft-1',
|
||||
expiresAt: new Date('2026-08-14T12:00:00.000Z'),
|
||||
authorizationUrl: `${BASE_URL}/api/auth/oauth2/authorize?draftId=draft-1`,
|
||||
})
|
||||
mockOAuth2LinkAccount.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ url: LINK_URL }),
|
||||
headers: { getSetCookie: () => ['better-auth.state=xyz; Path=/'] },
|
||||
mocks.launchConnection.mockResolvedValue({
|
||||
draft: {
|
||||
id: 'draft-1',
|
||||
providerId: 'google-email',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
credentialId: null,
|
||||
},
|
||||
})
|
||||
mocks.linkAccount.mockResolvedValue(linkResponse())
|
||||
})
|
||||
|
||||
describe('plain connect (no credentialId)', () => {
|
||||
it('creates a draft with credentialId null and redirects to the provider', async () => {
|
||||
const response = await GET(
|
||||
authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })
|
||||
)
|
||||
it('creates a canonical application draft for a legacy connect URL', async () => {
|
||||
const response = await GET(request({ providerId: 'google-email', workspaceId: WORKSPACE_ID }))
|
||||
|
||||
expect(response.headers.get('location')).toBe(LINK_URL)
|
||||
expect(mockGetCredentialActorContext).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: USER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
providerId: 'google-email',
|
||||
credentialId: null,
|
||||
})
|
||||
)
|
||||
expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
set: expect.objectContaining({ credentialId: null }),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('numbers the draft display name when the default collides with an existing credential', async () => {
|
||||
dbChainMockFns.where
|
||||
.mockImplementationOnce(() => Promise.resolve([{ name: 'Justin' }]))
|
||||
.mockImplementationOnce(() => Promise.resolve([{ displayName: "Justin's Gmail" }]))
|
||||
|
||||
await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }))
|
||||
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ displayName: "Justin's Gmail 2" })
|
||||
)
|
||||
})
|
||||
|
||||
it('nulls out credentialId in the upsert set so a stale reconnect draft cannot leak into a plain connect', async () => {
|
||||
await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }))
|
||||
|
||||
const [{ set }] = dbChainMockFns.onConflictDoUpdate.mock.calls[0]
|
||||
expect(set).toHaveProperty('credentialId', null)
|
||||
})
|
||||
|
||||
it('rejects an OAuth client that is not configured for the deployment', async () => {
|
||||
setEnv({ GOOGLE_CLIENT_ID: undefined, GOOGLE_CLIENT_SECRET: undefined })
|
||||
|
||||
const response = await GET(
|
||||
authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })
|
||||
)
|
||||
|
||||
expect(response.headers.get('location')).toBe(`${BASE_URL}/workspace?error=oauth_link_failed`)
|
||||
expect(dbChainMockFns.values).not.toHaveBeenCalled()
|
||||
expect(mockOAuth2LinkAccount).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('redirects to login when unauthenticated', async () => {
|
||||
mockGetSession.mockResolvedValue(null)
|
||||
|
||||
const response = await GET(
|
||||
authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })
|
||||
)
|
||||
|
||||
expect(response.headers.get('location')).toContain('/login')
|
||||
expect(dbChainMockFns.values).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects without workspace write access', async () => {
|
||||
mockCheckWorkspaceAccess.mockResolvedValue({
|
||||
hasAccess: true,
|
||||
canWrite: false,
|
||||
canAdmin: false,
|
||||
workspace: { id: WORKSPACE_ID },
|
||||
expect(response.headers.get('location')).toBe('https://provider.example/authorize')
|
||||
expect(mocks.createConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
|
||||
input: { workspaceId: WORKSPACE_ID, providerId: 'google-email' },
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })
|
||||
)
|
||||
|
||||
expect(response.headers.get('location')).toBe(
|
||||
`${BASE_URL}/workspace?error=workspace_access_denied`
|
||||
)
|
||||
expect(dbChainMockFns.values).not.toHaveBeenCalled()
|
||||
expect(mockOAuth2LinkAccount).not.toHaveBeenCalled()
|
||||
})
|
||||
)
|
||||
expect(mocks.linkAccount).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
providerId: 'google-email',
|
||||
callbackURL: expect.stringContaining('credentialDraftId=draft-1'),
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe('reconnect (credentialId present)', () => {
|
||||
it('creates a reconnect draft carrying credentialId in values and upsert set', async () => {
|
||||
mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor())
|
||||
|
||||
const response = await GET(
|
||||
authorizeRequest({
|
||||
providerId: 'google-email',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
credentialId: CREDENTIAL_ID,
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.headers.get('location')).toBe(LINK_URL)
|
||||
expect(mockGetCredentialActorContext).toHaveBeenCalledWith(
|
||||
CREDENTIAL_ID,
|
||||
USER_ID,
|
||||
expect.objectContaining({ workspaceAccess: expect.anything() })
|
||||
)
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ credentialId: CREDENTIAL_ID })
|
||||
)
|
||||
expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
set: expect.objectContaining({ credentialId: CREDENTIAL_ID }),
|
||||
})
|
||||
)
|
||||
it('requires a configured OAuth client before creating a legacy draft', async () => {
|
||||
mocks.requireClient.mockImplementationOnce(() => {
|
||||
throw new Error('OAuth client is not configured')
|
||||
})
|
||||
|
||||
it("uses the credential's actual display name for the reconnect draft (audit accuracy)", async () => {
|
||||
mockGetCredentialActorContext.mockResolvedValue(
|
||||
oauthCredentialActor({ credential: { displayName: 'Renamed By User' } })
|
||||
)
|
||||
const response = await GET(request({ providerId: 'google-email', workspaceId: WORKSPACE_ID }))
|
||||
|
||||
await GET(
|
||||
authorizeRequest({
|
||||
providerId: 'google-email',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
credentialId: CREDENTIAL_ID,
|
||||
})
|
||||
)
|
||||
expect(response.headers.get('location')).toBe(`${BASE_URL}/workspace?error=oauth_link_failed`)
|
||||
expect(mocks.requireClient).toHaveBeenCalledWith('google-email')
|
||||
expect(mocks.createConnection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ displayName: 'Renamed By User' })
|
||||
)
|
||||
it('launches an exact draft without creating another one', async () => {
|
||||
const response = await GET(request({ draftId: 'draft-1' }))
|
||||
|
||||
expect(response.headers.get('location')).toBe('https://provider.example/authorize')
|
||||
expect(mocks.launchConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ input: { draftId: 'draft-1' } })
|
||||
)
|
||||
expect(mocks.createConnection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes reconnect provider assertions through the application use case', async () => {
|
||||
mocks.createConnection.mockResolvedValue({
|
||||
providerId: 'google-email',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
credentialId: 'credential-1',
|
||||
draftId: 'draft-1',
|
||||
expiresAt: new Date(),
|
||||
authorizationUrl: '',
|
||||
})
|
||||
|
||||
it('rejects reconnect for custom-flow providers (trello/shopify) and writes no draft', async () => {
|
||||
for (const providerId of ['trello', 'shopify']) {
|
||||
const response = await GET(
|
||||
authorizeRequest({ providerId, workspaceId: WORKSPACE_ID, credentialId: CREDENTIAL_ID })
|
||||
)
|
||||
|
||||
expect(response.headers.get('location')).toBe(
|
||||
`${BASE_URL}/workspace?error=credential_reconnect_unsupported`
|
||||
)
|
||||
}
|
||||
expect(mockGetCredentialActorContext).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.values).not.toHaveBeenCalled()
|
||||
expect(mockOAuth2LinkAccount).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects when the caller is not a credential admin and writes no draft', async () => {
|
||||
mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor({ isAdmin: false }))
|
||||
|
||||
const response = await GET(
|
||||
authorizeRequest({
|
||||
providerId: 'google-email',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
credentialId: CREDENTIAL_ID,
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.headers.get('location')).toBe(
|
||||
`${BASE_URL}/workspace?error=credential_access_denied`
|
||||
)
|
||||
expect(dbChainMockFns.values).not.toHaveBeenCalled()
|
||||
expect(mockOAuth2LinkAccount).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects when the credential belongs to a different workspace', async () => {
|
||||
mockGetCredentialActorContext.mockResolvedValue(
|
||||
oauthCredentialActor({ credential: { workspaceId: 'ws-other' } })
|
||||
)
|
||||
|
||||
const response = await GET(
|
||||
authorizeRequest({
|
||||
providerId: 'google-email',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
credentialId: CREDENTIAL_ID,
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.headers.get('location')).toBe(
|
||||
`${BASE_URL}/workspace?error=credential_access_denied`
|
||||
)
|
||||
expect(dbChainMockFns.values).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects when the credential does not exist', async () => {
|
||||
mockGetCredentialActorContext.mockResolvedValue({
|
||||
credential: null,
|
||||
member: null,
|
||||
hasWorkspaceAccess: false,
|
||||
canWriteWorkspace: false,
|
||||
isAdmin: false,
|
||||
await GET(
|
||||
request({
|
||||
providerId: 'google-email',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
credentialId: 'credential-1',
|
||||
})
|
||||
)
|
||||
|
||||
const response = await GET(
|
||||
authorizeRequest({
|
||||
providerId: 'google-email',
|
||||
expect(mocks.createConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: {
|
||||
workspaceId: WORKSPACE_ID,
|
||||
credentialId: 'cred-missing',
|
||||
})
|
||||
)
|
||||
credentialId: 'credential-1',
|
||||
assertedProviderId: 'google-email',
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
expect(response.headers.get('location')).toBe(
|
||||
`${BASE_URL}/workspace?error=credential_access_denied`
|
||||
it('maps a provider mismatch without exposing the credential', async () => {
|
||||
mocks.createConnection.mockRejectedValue(
|
||||
new CredentialConnectionProviderMismatchError('google-email', 'slack')
|
||||
)
|
||||
|
||||
const response = await GET(
|
||||
request({
|
||||
providerId: 'slack',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
credentialId: 'credential-1',
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.headers.get('location')).toBe(
|
||||
`${BASE_URL}/workspace?error=credential_provider_mismatch`
|
||||
)
|
||||
})
|
||||
|
||||
it('maps credential and workspace authorization failures separately', async () => {
|
||||
mocks.createConnection.mockRejectedValueOnce(
|
||||
new ForbiddenOperationError(
|
||||
'CREDENTIAL_ADMIN_ACCESS_REQUIRED',
|
||||
'Credential admin permission required'
|
||||
)
|
||||
expect(dbChainMockFns.values).not.toHaveBeenCalled()
|
||||
)
|
||||
const credentialResponse = await GET(
|
||||
request({
|
||||
providerId: 'google-email',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
credentialId: 'credential-1',
|
||||
})
|
||||
)
|
||||
mocks.createConnection.mockRejectedValueOnce(
|
||||
new OrchestrationError('forbidden', 'Write permission required')
|
||||
)
|
||||
const workspaceResponse = await GET(
|
||||
request({ providerId: 'google-email', workspaceId: WORKSPACE_ID })
|
||||
)
|
||||
|
||||
expect(credentialResponse.headers.get('location')).toContain('credential_access_denied')
|
||||
expect(workspaceResponse.headers.get('location')).toContain('workspace_access_denied')
|
||||
})
|
||||
|
||||
it('keeps a reconnect workspace-role denial classified as workspace access', async () => {
|
||||
mocks.createConnection.mockRejectedValue(new InsufficientWorkspacePermissionsError())
|
||||
|
||||
const response = await GET(
|
||||
request({
|
||||
providerId: 'google-email',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
credentialId: 'credential-1',
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.headers.get('location')).toBe(
|
||||
`${BASE_URL}/workspace?error=workspace_access_denied`
|
||||
)
|
||||
})
|
||||
|
||||
it('redirects a draft launch infrastructure failure through the browser error contract', async () => {
|
||||
mocks.launchConnection.mockRejectedValue(new Error('Database unavailable'))
|
||||
|
||||
const response = await GET(request({ draftId: 'draft-1' }))
|
||||
|
||||
expect(response.headers.get('location')).toBe(`${BASE_URL}/workspace?error=oauth_link_failed`)
|
||||
})
|
||||
|
||||
it('routes custom providers through the exact application draft', async () => {
|
||||
mocks.createConnection.mockResolvedValue({
|
||||
providerId: 'trello',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
draftId: 'draft-1',
|
||||
expiresAt: new Date(),
|
||||
authorizationUrl: '',
|
||||
})
|
||||
|
||||
it('rejects a non-oauth credential', async () => {
|
||||
mockGetCredentialActorContext.mockResolvedValue(
|
||||
oauthCredentialActor({ credential: { type: 'env_workspace' } })
|
||||
)
|
||||
const response = await GET(request({ providerId: 'trello', workspaceId: WORKSPACE_ID }))
|
||||
const location = new URL(response.headers.get('location') ?? '')
|
||||
|
||||
const response = await GET(
|
||||
authorizeRequest({
|
||||
providerId: 'google-email',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
credentialId: CREDENTIAL_ID,
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.headers.get('location')).toBe(
|
||||
`${BASE_URL}/workspace?error=credential_access_denied`
|
||||
)
|
||||
expect(dbChainMockFns.values).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects when the query providerId does not match the credential provider', async () => {
|
||||
mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor())
|
||||
|
||||
const response = await GET(
|
||||
authorizeRequest({
|
||||
providerId: 'slack',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
credentialId: CREDENTIAL_ID,
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.headers.get('location')).toBe(
|
||||
`${BASE_URL}/workspace?error=credential_provider_mismatch`
|
||||
)
|
||||
expect(dbChainMockFns.values).not.toHaveBeenCalled()
|
||||
expect(mockOAuth2LinkAccount).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(location.pathname).toBe('/api/auth/trello/authorize')
|
||||
expect(location.searchParams.get('draftId')).toBe('draft-1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,12 +3,15 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { authorizeOAuth2Contract } from '@/lib/api/contracts/oauth-connections'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { auth, getSession } from '@/lib/auth/auth'
|
||||
import { ForbiddenOperationError } from '@/lib/core/application/forbidden'
|
||||
import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server'
|
||||
import { OrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { getCredentialActorContext } from '@/lib/credentials/access'
|
||||
import { createConnectDraft } from '@/lib/credentials/connect-draft'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
import { CredentialConnectionProviderMismatchError } from '@/lib/credentials/application/connection-target'
|
||||
import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection'
|
||||
import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection'
|
||||
import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-processor'
|
||||
|
||||
const logger = createLogger('OAuth2Authorize')
|
||||
|
||||
@@ -27,95 +30,109 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
return NextResponse.redirect(loginUrl.toString())
|
||||
}
|
||||
const userId = session.user.id
|
||||
const sessionId = session.session?.id
|
||||
if (!sessionId) throw new Error('Authenticated session is missing its session ID')
|
||||
const principal = { kind: 'session' as const, userId, sessionId }
|
||||
|
||||
const parsed = await parseRequest(authorizeOAuth2Contract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const {
|
||||
providerId,
|
||||
workspaceId,
|
||||
callbackURL: requestedCallback,
|
||||
credentialId,
|
||||
} = parsed.data.query
|
||||
|
||||
const callbackURL = requestedCallback?.startsWith(`${baseUrl}/`)
|
||||
? requestedCallback
|
||||
: `${baseUrl}/workspace`
|
||||
const { draftId } = parsed.data.query
|
||||
let { providerId, workspaceId, callbackURL: requestedCallback, credentialId } = parsed.data.query
|
||||
|
||||
try {
|
||||
const access = await checkWorkspaceAccess(workspaceId, userId)
|
||||
if (!access.canWrite) {
|
||||
logger.warn('Workspace write access denied for OAuth2 authorize', {
|
||||
userId,
|
||||
workspaceId,
|
||||
providerId,
|
||||
})
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`)
|
||||
let fromConnectionDraft = false
|
||||
let connectionDraftId: string | undefined
|
||||
if (draftId) {
|
||||
try {
|
||||
const { draft } = await launchCredentialConnection.execute({
|
||||
principal,
|
||||
input: { draftId },
|
||||
request,
|
||||
})
|
||||
providerId = draft.providerId
|
||||
workspaceId = draft.workspaceId
|
||||
credentialId = draft.credentialId ?? undefined
|
||||
connectionDraftId = draft.id
|
||||
fromConnectionDraft = true
|
||||
} catch (error) {
|
||||
if (!(error instanceof OrchestrationError)) throw error
|
||||
logger.warn('Rejected OAuth connection draft', { userId, draftId, code: error.code })
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_invalid`)
|
||||
}
|
||||
}
|
||||
|
||||
let reconnectDisplayName: string | undefined
|
||||
if (credentialId) {
|
||||
// Trello and Shopify authorize through their own custom flows that bypass
|
||||
// this endpoint, so a reconnect draft written here would linger unconsumed
|
||||
// and could later be picked up by their token-store callbacks, silently
|
||||
// rebinding the credential. Mirror the copilot tool and reject reconnect.
|
||||
if (providerId === 'trello' || providerId === 'shopify') {
|
||||
logger.warn('Reconnect not supported for custom-flow provider', {
|
||||
userId,
|
||||
workspaceId,
|
||||
providerId,
|
||||
credentialId,
|
||||
})
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=credential_reconnect_unsupported`)
|
||||
}
|
||||
|
||||
// Reconnect: the OAuth callback will rebind this credential to the fresh
|
||||
// account, so require the same credential-admin access as the draft POST
|
||||
// route — workspace write alone must not be enough to swap someone's tokens.
|
||||
const actor = await getCredentialActorContext(credentialId, userId, {
|
||||
workspaceAccess: access,
|
||||
})
|
||||
if (
|
||||
!actor.credential ||
|
||||
actor.credential.workspaceId !== workspaceId ||
|
||||
actor.credential.type !== 'oauth' ||
|
||||
!actor.isAdmin
|
||||
) {
|
||||
logger.warn('Credential admin access denied for OAuth2 reconnect', {
|
||||
userId,
|
||||
workspaceId,
|
||||
providerId,
|
||||
credentialId,
|
||||
})
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=credential_access_denied`)
|
||||
}
|
||||
if (actor.credential.providerId !== providerId) {
|
||||
logger.warn('Provider mismatch for OAuth2 reconnect', {
|
||||
userId,
|
||||
workspaceId,
|
||||
providerId,
|
||||
credentialId,
|
||||
credentialProviderId: actor.credential.providerId,
|
||||
})
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`)
|
||||
}
|
||||
reconnectDisplayName = actor.credential.displayName
|
||||
if (!providerId || !workspaceId) {
|
||||
throw new Error('Validated OAuth authorization request is missing its target')
|
||||
}
|
||||
|
||||
requireConfiguredOAuthClient(providerId)
|
||||
|
||||
// Create the draft before initiating the link so it is guaranteed to exist
|
||||
// (and freshly clocked) when the OAuth callback's `account.create.after`
|
||||
// hook runs. If this throws, we never start the OAuth flow.
|
||||
await createConnectDraft({
|
||||
userId,
|
||||
workspaceId,
|
||||
providerId,
|
||||
credentialId,
|
||||
displayName: reconnectDisplayName,
|
||||
})
|
||||
const connectionCompleteUrl = new URL('/oauth/credential-connected', baseUrl)
|
||||
connectionCompleteUrl.searchParams.set('result', 'connected')
|
||||
const callbackURL = fromConnectionDraft
|
||||
? connectionCompleteUrl.toString()
|
||||
: requestedCallback?.startsWith(`${baseUrl}/`)
|
||||
? requestedCallback
|
||||
: `${baseUrl}/workspace`
|
||||
|
||||
if (!fromConnectionDraft) {
|
||||
try {
|
||||
const connection = await createCredentialConnection.execute({
|
||||
principal,
|
||||
input: credentialId
|
||||
? { workspaceId, credentialId, assertedProviderId: providerId }
|
||||
: { workspaceId, providerId },
|
||||
request,
|
||||
})
|
||||
providerId = connection.providerId
|
||||
workspaceId = connection.workspaceId
|
||||
credentialId = connection.credentialId
|
||||
connectionDraftId = connection.draftId
|
||||
} catch (error) {
|
||||
if (error instanceof CredentialConnectionProviderMismatchError) {
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`)
|
||||
}
|
||||
if (
|
||||
credentialId &&
|
||||
error instanceof ForbiddenOperationError &&
|
||||
error.detailCode === 'CREDENTIAL_ADMIN_ACCESS_REQUIRED'
|
||||
) {
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=credential_access_denied`)
|
||||
}
|
||||
if (error instanceof OrchestrationError && error.code === 'not_found') {
|
||||
return NextResponse.redirect(
|
||||
`${baseUrl}/workspace?error=${credentialId ? 'credential_access_denied' : 'workspace_access_denied'}`
|
||||
)
|
||||
}
|
||||
if (error instanceof OrchestrationError && error.code === 'forbidden') {
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (!connectionDraftId) {
|
||||
throw new Error('OAuth authorization is missing its credential draft id')
|
||||
}
|
||||
|
||||
if (providerId === 'trello' || providerId === 'instagram' || providerId === 'shopify') {
|
||||
const authorizeUrl = new URL(`/api/auth/${providerId}/authorize`, baseUrl)
|
||||
authorizeUrl.searchParams.set('returnUrl', callbackURL)
|
||||
authorizeUrl.searchParams.set('draftId', connectionDraftId)
|
||||
return NextResponse.redirect(authorizeUrl)
|
||||
}
|
||||
|
||||
const stateCallbackUrl = new URL(callbackURL)
|
||||
stateCallbackUrl.searchParams.set(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM, connectionDraftId)
|
||||
|
||||
const linkResponse = await auth.api.oAuth2LinkAccount({
|
||||
body: { providerId, callbackURL },
|
||||
body: {
|
||||
providerId,
|
||||
callbackURL: stateCallbackUrl.toString(),
|
||||
...(fromConnectionDraft
|
||||
? { errorCallbackURL: `${baseUrl}/oauth/credential-connected?result=failed` }
|
||||
: {}),
|
||||
},
|
||||
headers: request.headers,
|
||||
asResponse: true,
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user