diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml
index 4b5a0fb1404..6b610b94be1 100644
--- a/.github/workflows/test-build.yml
+++ b/.github/workflows/test-build.yml
@@ -123,6 +123,9 @@ jobs:
- name: API contract boundary audit
run: bun run check:api-validation:strict
+ - name: OpenAPI spec validation
+ run: bun run check:openapi
+
- name: Desktop bridge contract audit
run: bun run check:desktop-bridge
diff --git a/apps/docs/content/docs/de/api-reference/getting-started.mdx b/apps/docs/content/docs/de/api-reference/getting-started.mdx
index 25c8cfdbf2e..7e94ab0d7bd 100644
--- a/apps/docs/content/docs/de/api-reference/getting-started.mdx
+++ b/apps/docs/content/docs/de/api-reference/getting-started.mdx
@@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`:
}
```
-Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`:
+Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`:
```bash
curl https://www.sim.ai/api/jobs/{jobId} \
diff --git a/apps/docs/content/docs/de/api-reference/meta.json b/apps/docs/content/docs/de/api-reference/meta.json
index d8a1fb142c6..f4a24c829e1 100644
--- a/apps/docs/content/docs/de/api-reference/meta.json
+++ b/apps/docs/content/docs/de/api-reference/meta.json
@@ -2,6 +2,7 @@
"title": "API Reference",
"root": true,
"pages": [
+ "---Getting Started---",
"getting-started",
"authentication",
"---SDKs---",
@@ -10,9 +11,17 @@
"---Endpoints---",
"(generated)/workflows",
"(generated)/logs",
- "(generated)/usage",
"(generated)/audit-logs",
"(generated)/tables",
- "(generated)/files"
+ "(generated)/files",
+ "(generated)/knowledge-bases",
+ "(generated)/mcp-servers",
+ "(generated)/skills",
+ "(generated)/custom-tools",
+ "(generated)/credentials",
+ "---Execution and Usage---",
+ "(generated)/execution",
+ "(generated)/human-in-the-loop",
+ "(generated)/usage"
]
}
diff --git a/apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json b/apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json
new file mode 100644
index 00000000000..52458d430c3
--- /dev/null
+++ b/apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json
@@ -0,0 +1,3 @@
+{
+ "pages": ["executeWorkflow", "getWorkflowExecution", "cancelExecution", "getJobStatus"]
+}
diff --git a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json
index 8e2caa1abe8..d5e28d23d63 100644
--- a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json
+++ b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json
@@ -1,8 +1,5 @@
{
"pages": [
- "executeWorkflow",
- "getWorkflowExecution",
- "cancelExecution",
"listWorkflows",
"getWorkflow",
"exportWorkflow",
@@ -10,6 +7,8 @@
"deployWorkflow",
"undeployWorkflow",
"rollbackWorkflow",
- "getJobStatus"
+ "executeWorkflowV2",
+ "getWorkflowExecutionV2",
+ "cancelExecutionV2"
]
}
diff --git a/apps/docs/content/docs/en/api-reference/getting-started.mdx b/apps/docs/content/docs/en/api-reference/getting-started.mdx
index 038998853cf..c8093e72c14 100644
--- a/apps/docs/content/docs/en/api-reference/getting-started.mdx
+++ b/apps/docs/content/docs/en/api-reference/getting-started.mdx
@@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`:
}
```
-Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`:
+Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`:
```bash
curl https://www.sim.ai/api/jobs/{jobId} \
diff --git a/apps/docs/content/docs/en/api-reference/meta.json b/apps/docs/content/docs/en/api-reference/meta.json
index c99ab8eb13f..f4a24c829e1 100644
--- a/apps/docs/content/docs/en/api-reference/meta.json
+++ b/apps/docs/content/docs/en/api-reference/meta.json
@@ -10,12 +10,18 @@
"typescript",
"---Endpoints---",
"(generated)/workflows",
- "(generated)/human-in-the-loop",
"(generated)/logs",
- "(generated)/usage",
"(generated)/audit-logs",
"(generated)/tables",
"(generated)/files",
- "(generated)/knowledge-bases"
+ "(generated)/knowledge-bases",
+ "(generated)/mcp-servers",
+ "(generated)/skills",
+ "(generated)/custom-tools",
+ "(generated)/credentials",
+ "---Execution and Usage---",
+ "(generated)/execution",
+ "(generated)/human-in-the-loop",
+ "(generated)/usage"
]
}
diff --git a/apps/docs/content/docs/en/integrations/zoho_desk.mdx b/apps/docs/content/docs/en/integrations/zoho_desk.mdx
index 2081c2a2a14..8f32c2d419a 100644
--- a/apps/docs/content/docs/en/integrations/zoho_desk.mdx
+++ b/apps/docs/content/docs/en/integrations/zoho_desk.mdx
@@ -5,7 +5,7 @@ description: Manage Zoho Desk tickets, comments, threads, and contacts
import { BlockInfoCard } from "@/components/ui/block-info-card"
-
@@ -512,4 +512,3 @@ Trigger a workflow when a Zoho Desk event occurs (ticket, comment, thread, conta
| `orgId` | string | Zoho Desk organization ID |
| `payload` | json | The full resource that changed \(ticket, comment, thread, etc.\). Comment and thread events gain a derived plain-text `contentText` alongside the raw `content` + `contentType`; ticket events gain `descriptionText` alongside `description`. |
| `prevState` | json | Previous state of the resource \(update events only\) |
-
diff --git a/apps/docs/content/docs/en/platform/enterprise/audit-logs.mdx b/apps/docs/content/docs/en/platform/enterprise/audit-logs.mdx
index 9bcf9dfb0ed..b9d039c2a56 100644
--- a/apps/docs/content/docs/en/platform/enterprise/audit-logs.mdx
+++ b/apps/docs/content/docs/en/platform/enterprise/audit-logs.mdx
@@ -33,7 +33,7 @@ Audit logs are also accessible through the Sim API for integration with external
```http
GET /api/v1/audit-logs
-Authorization: Bearer
+X-API-Key:
```
**Query parameters:**
@@ -71,11 +71,18 @@ Authorization: Bearer
"createdAt": "2026-04-20T21:16:00.000Z"
}
],
- "nextCursor": "eyJpZCI6ImFiYzEyMyJ9"
+ "nextCursor": "eyJpZCI6ImFiYzEyMyJ9",
+ "limits": {
+ "workflowExecutionRateLimit": {
+ "sync": { "requestsPerMinute": 60, "maxBurst": 10, "remaining": 59, "resetAt": "2026-04-20T21:17:00.000Z" },
+ "async": { "requestsPerMinute": 30, "maxBurst": 5, "remaining": 30, "resetAt": "2026-04-20T21:17:00.000Z" }
+ },
+ "usage": { "currentPeriodCost": 1.25, "limit": 50, "plan": "enterprise", "isExceeded": false }
+ }
}
```
-Paginate by passing the `nextCursor` value as the `cursor` parameter in the next request. When `nextCursor` is absent, you have reached the last page.
+Paginate by passing the `nextCursor` value as the `cursor` parameter in the next request. When `nextCursor` is absent, you have reached the last page. Each entry also includes `actorName`; `metadata` is an arbitrary per-action JSON object. The `limits` object reports your current rate-limit and usage status.
The API accepts both personal and workspace-scoped API keys. Rate limits apply — the response includes `X-RateLimit-*` headers with your current limit and remaining quota.
diff --git a/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx b/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx
index 64716f9f0f3..7a6bc9c8a69 100644
--- a/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx
+++ b/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx
@@ -78,7 +78,6 @@ cat > /tmp/cors.json <<'EOF'
"AllowedOrigins": ["https://sim.yourdomain.com"],
"AllowedMethods": ["GET", "PUT"],
"AllowedHeaders": ["*"],
- "ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
@@ -91,10 +90,6 @@ for name in workspace-files knowledge-base execution-files chat-files \
done
```
-
- `ExposeHeaders` **must** include `ETag`. Files larger than 50 MB use multipart uploads, and the browser reads each part's `ETag` to complete the upload — CORS hides the header otherwise and large uploads fail at the final step.
-
-
Set `AllowedOrigins` to your exact Sim origin (scheme + host, no trailing slash). Add every origin users reach Sim from, including an apex/`www` pair if both are live.
@@ -237,6 +232,26 @@ AZURE_STORAGE_OG_IMAGES_CONTAINER_NAME=og-images
AZURE_STORAGE_WORKSPACE_LOGOS_CONTAINER_NAME=workspace-logos
```
+Direct browser uploads require a Blob service CORS rule on the storage account. Allow your exact
+Sim origin, `GET` and `PUT`, the `Content-Type` header, and the `x-ms-*` prefix used by signed blob
+and metadata headers. Small-file uploads also send `If-None-Match` so a signed URL cannot overwrite
+an existing final object:
+
+```bash
+az storage cors add \
+ --services b \
+ --methods GET PUT \
+ --origins https://sim.yourdomain.com \
+ --allowed-headers content-type if-none-match 'x-ms-*' \
+ --max-age 3600 \
+ --account-name mystorageaccount \
+ --account-key ''
+```
+
+If you authenticate with a connection string, replace the last two options with
+`--connection-string "$AZURE_CONNECTION_STRING"`. CORS is configured once for the account's Blob
+service and applies to all of its containers.
+
A full Helm example lives at `helm/sim/examples/values-azure.yaml`.
## Set up Google Cloud Storage
@@ -275,12 +290,14 @@ cat > /tmp/cors.json <<'EOF'
"method": ["GET", "PUT"],
"responseHeader": [
"Content-Type",
- "ETag",
+ "x-goog-if-generation-match",
+ "x-goog-meta-uploadid",
"x-goog-meta-originalname",
"x-goog-meta-uploadedat",
"x-goog-meta-purpose",
"x-goog-meta-userid",
"x-goog-meta-workspaceid",
+ "x-goog-meta-knowledgebaseid",
"x-goog-meta-folderid",
"x-goog-meta-workflowid",
"x-goog-meta-executionid"
@@ -297,7 +314,7 @@ done
```
- Header names must be listed individually — GCS CORS matches `responseHeader` entries exactly and does not support wildcards like `x-goog-meta-*`. `ETag` is required because large-file multipart uploads read each part's `ETag` from the browser, and CORS hides the header otherwise.
+ Header names must be listed individually — GCS CORS matches `responseHeader` entries exactly and does not support wildcards like `x-goog-meta-*`. `x-goog-if-generation-match` makes small-file uploads create-only; Sim obtains multipart ETags from GCS during completion rather than exposing them to the browser.
@@ -445,6 +462,27 @@ The same browser-reachability and CORS requirements apply.
+## Configure incomplete multipart cleanup
+
+Sim uploads directly to a create-only final object key and keeps upload-session state in PostgreSQL.
+The cleanup cron claims expired sessions before deleting an uploaded object or aborting its provider
+multipart state. Configure provider lifecycle cleanup as a second line of defense for multipart
+state that outlives its database row:
+
+- On AWS S3 and Google Cloud Storage, abort incomplete multipart uploads after two days on every
+ purpose-specific bucket.
+- Azure automatically removes uncommitted blocks after seven days.
+- For an S3-compatible provider, configure incomplete-multipart cleanup when its lifecycle
+ implementation supports it. Check the provider's documentation because support varies.
+
+The provider window should exceed the 24-hour upload-session lifetime so an in-progress completion
+can still recover. Do not add an object-expiration rule for final upload keys.
+
+
+ Object expiration and incomplete-multipart cleanup are different lifecycle operations. Configure
+ the incomplete-multipart operation; expiring objects does not remove abandoned multipart parts.
+
+
## Verify it works
After restarting with the new configuration:
diff --git a/apps/docs/content/docs/en/platform/self-hosting/platforms.mdx b/apps/docs/content/docs/en/platform/self-hosting/platforms.mdx
index 72152909258..d2dd3eb601a 100644
--- a/apps/docs/content/docs/en/platform/self-hosting/platforms.mdx
+++ b/apps/docs/content/docs/en/platform/self-hosting/platforms.mdx
@@ -49,4 +49,4 @@ Recommended for any production deployment. The requirement is **pgvector**.
DATABASE_URL="postgresql://user:pass@host:5432/simstudio?sslmode=require"
```
-For the Helm chart, disable the bundled Postgres and use `externalDatabase` — see [Kubernetes](/platform/self-hosting/kubernetes#external-database).
+For the Helm chart, disable the bundled Postgres and use `externalDatabase` — see [Kubernetes](/platform/self-hosting/kubernetes#external-database).
diff --git a/apps/docs/content/docs/en/platform/self-hosting/troubleshooting.mdx b/apps/docs/content/docs/en/platform/self-hosting/troubleshooting.mdx
index ea28f8b3a1f..c4b8406ad5d 100644
--- a/apps/docs/content/docs/en/platform/self-hosting/troubleshooting.mdx
+++ b/apps/docs/content/docs/en/platform/self-hosting/troubleshooting.mdx
@@ -193,7 +193,7 @@ Both pods must have `REDIS_URL`. On Helm they share one Secret, so setting it un
The bucket's CORS policy does not allow your Sim origin. Uploads go directly from the browser to object storage via presigned `PUT`, so server-side configuration being correct is not enough.
-If small uploads succeed but files over 50 MB fail at the last step, `ETag` is missing from the CORS exposed headers — multipart uploads read it from the browser. See [Object Storage](/platform/self-hosting/object-storage).
+If small uploads succeed but files over 50 MB fail during completion, check the app logs for the provider's part-listing request. The server completes multipart uploads from provider-authoritative state; for S3, its identity needs `s3:ListMultipartUploadParts`. See [Object Storage](/platform/self-hosting/object-storage).
## Agent Output Arrives All at Once
diff --git a/apps/docs/content/docs/en/platform/self-hosting/verify.mdx b/apps/docs/content/docs/en/platform/self-hosting/verify.mdx
index 77bfb439380..d34e35a2ad3 100644
--- a/apps/docs/content/docs/en/platform/self-hosting/verify.mdx
+++ b/apps/docs/content/docs/en/platform/self-hosting/verify.mdx
@@ -18,7 +18,7 @@ Run this after a first install, after an upgrade, and after a restore. Each step
| 4 | Open the same workflow in a second browser window and edit | Cross-replica collaboration | With >1 replica this needs [Redis](/platform/self-hosting/redis) |
| 5 | Paste a model API key in settings and run a two-block workflow | Execution engine, credential encryption, outbound network | App logs; check `ENCRYPTION_KEY` is set and outbound egress is allowed |
| 6 | Upload a small file in Files | File storage end to end | With object storage configured: presigned URL + bucket CORS. On local disk: the upload proxies through the app |
-| 7 | Upload a file larger than 50 MB | Multipart upload path (object storage only) | Confirm `ETag` is in the bucket's CORS exposed headers |
+| 7 | Upload a file larger than 50 MB | Multipart upload path (object storage only) | Check app logs for provider part-listing or completion errors |
| 8 | Create a knowledge base and upload a PDF | Document parsing, embeddings, pgvector | Needs a hosted embedding provider — see below |
| 9 | Invite a teammate from workspace settings | Email delivery | App logs for the mailer; see [Email](/platform/self-hosting/email) |
| 10 | Connect an integration account | OAuth configuration | Redirect URI mismatch → see [Integrations & OAuth](/platform/self-hosting/integrations-oauth) |
@@ -72,7 +72,7 @@ All six should be present on Compose: `simstudio`, `realtime`, `db`, `redis`, `c
**Step 5 fails — execution errors.** Check outbound connectivity to the model provider, then the app logs. If the error is about decrypting a credential, `ENCRYPTION_KEY` differs from the one that encrypted it.
-**Step 6 or 7 fails.** With object storage configured, a CORS error in the browser console means the bucket policy does not allow your Sim origin; step 7 failing while step 6 passes specifically means `ETag` is missing from the exposed headers. On local-disk storage there is no CORS involved — uploads proxy through the app, so look at the app logs and the proxy body-size limit instead.
+**Step 6 or 7 fails.** With object storage configured, a CORS error in the browser console means the bucket policy does not allow your Sim origin or the signed upload headers. If step 7 fails only during completion, check the app logs and verify the server identity can list multipart parts (for S3, `s3:ListMultipartUploadParts`). On local-disk storage there is no CORS involved — uploads proxy through the app, so look at the app logs and the proxy body-size limit instead.
**Step 8 fails — knowledge base upload errors.** Knowledge bases need a hosted embedding provider — OpenAI, Azure OpenAI, or Gemini. There is no local embedding backend. If a key is set, check pgvector is installed on the database.
diff --git a/apps/docs/content/docs/es/api-reference/getting-started.mdx b/apps/docs/content/docs/es/api-reference/getting-started.mdx
index 038998853cf..c8093e72c14 100644
--- a/apps/docs/content/docs/es/api-reference/getting-started.mdx
+++ b/apps/docs/content/docs/es/api-reference/getting-started.mdx
@@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`:
}
```
-Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`:
+Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`:
```bash
curl https://www.sim.ai/api/jobs/{jobId} \
diff --git a/apps/docs/content/docs/es/api-reference/meta.json b/apps/docs/content/docs/es/api-reference/meta.json
index c96dc5d2edc..f4a24c829e1 100644
--- a/apps/docs/content/docs/es/api-reference/meta.json
+++ b/apps/docs/content/docs/es/api-reference/meta.json
@@ -2,6 +2,7 @@
"title": "API Reference",
"root": true,
"pages": [
+ "---Getting Started---",
"getting-started",
"authentication",
"---SDKs---",
@@ -10,7 +11,17 @@
"---Endpoints---",
"(generated)/workflows",
"(generated)/logs",
- "(generated)/usage",
- "(generated)/audit-logs"
+ "(generated)/audit-logs",
+ "(generated)/tables",
+ "(generated)/files",
+ "(generated)/knowledge-bases",
+ "(generated)/mcp-servers",
+ "(generated)/skills",
+ "(generated)/custom-tools",
+ "(generated)/credentials",
+ "---Execution and Usage---",
+ "(generated)/execution",
+ "(generated)/human-in-the-loop",
+ "(generated)/usage"
]
}
diff --git a/apps/docs/content/docs/fr/api-reference/getting-started.mdx b/apps/docs/content/docs/fr/api-reference/getting-started.mdx
index 038998853cf..c8093e72c14 100644
--- a/apps/docs/content/docs/fr/api-reference/getting-started.mdx
+++ b/apps/docs/content/docs/fr/api-reference/getting-started.mdx
@@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`:
}
```
-Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`:
+Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`:
```bash
curl https://www.sim.ai/api/jobs/{jobId} \
diff --git a/apps/docs/content/docs/fr/api-reference/meta.json b/apps/docs/content/docs/fr/api-reference/meta.json
index c96dc5d2edc..f4a24c829e1 100644
--- a/apps/docs/content/docs/fr/api-reference/meta.json
+++ b/apps/docs/content/docs/fr/api-reference/meta.json
@@ -2,6 +2,7 @@
"title": "API Reference",
"root": true,
"pages": [
+ "---Getting Started---",
"getting-started",
"authentication",
"---SDKs---",
@@ -10,7 +11,17 @@
"---Endpoints---",
"(generated)/workflows",
"(generated)/logs",
- "(generated)/usage",
- "(generated)/audit-logs"
+ "(generated)/audit-logs",
+ "(generated)/tables",
+ "(generated)/files",
+ "(generated)/knowledge-bases",
+ "(generated)/mcp-servers",
+ "(generated)/skills",
+ "(generated)/custom-tools",
+ "(generated)/credentials",
+ "---Execution and Usage---",
+ "(generated)/execution",
+ "(generated)/human-in-the-loop",
+ "(generated)/usage"
]
}
diff --git a/apps/docs/content/docs/ja/api-reference/getting-started.mdx b/apps/docs/content/docs/ja/api-reference/getting-started.mdx
index 038998853cf..c8093e72c14 100644
--- a/apps/docs/content/docs/ja/api-reference/getting-started.mdx
+++ b/apps/docs/content/docs/ja/api-reference/getting-started.mdx
@@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`:
}
```
-Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`:
+Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`:
```bash
curl https://www.sim.ai/api/jobs/{jobId} \
diff --git a/apps/docs/content/docs/ja/api-reference/meta.json b/apps/docs/content/docs/ja/api-reference/meta.json
index c96dc5d2edc..f4a24c829e1 100644
--- a/apps/docs/content/docs/ja/api-reference/meta.json
+++ b/apps/docs/content/docs/ja/api-reference/meta.json
@@ -2,6 +2,7 @@
"title": "API Reference",
"root": true,
"pages": [
+ "---Getting Started---",
"getting-started",
"authentication",
"---SDKs---",
@@ -10,7 +11,17 @@
"---Endpoints---",
"(generated)/workflows",
"(generated)/logs",
- "(generated)/usage",
- "(generated)/audit-logs"
+ "(generated)/audit-logs",
+ "(generated)/tables",
+ "(generated)/files",
+ "(generated)/knowledge-bases",
+ "(generated)/mcp-servers",
+ "(generated)/skills",
+ "(generated)/custom-tools",
+ "(generated)/credentials",
+ "---Execution and Usage---",
+ "(generated)/execution",
+ "(generated)/human-in-the-loop",
+ "(generated)/usage"
]
}
diff --git a/apps/docs/content/docs/zh/api-reference/getting-started.mdx b/apps/docs/content/docs/zh/api-reference/getting-started.mdx
index 038998853cf..c8093e72c14 100644
--- a/apps/docs/content/docs/zh/api-reference/getting-started.mdx
+++ b/apps/docs/content/docs/zh/api-reference/getting-started.mdx
@@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`:
}
```
-Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`:
+Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`:
```bash
curl https://www.sim.ai/api/jobs/{jobId} \
diff --git a/apps/docs/content/docs/zh/api-reference/meta.json b/apps/docs/content/docs/zh/api-reference/meta.json
index c96dc5d2edc..f4a24c829e1 100644
--- a/apps/docs/content/docs/zh/api-reference/meta.json
+++ b/apps/docs/content/docs/zh/api-reference/meta.json
@@ -2,6 +2,7 @@
"title": "API Reference",
"root": true,
"pages": [
+ "---Getting Started---",
"getting-started",
"authentication",
"---SDKs---",
@@ -10,7 +11,17 @@
"---Endpoints---",
"(generated)/workflows",
"(generated)/logs",
- "(generated)/usage",
- "(generated)/audit-logs"
+ "(generated)/audit-logs",
+ "(generated)/tables",
+ "(generated)/files",
+ "(generated)/knowledge-bases",
+ "(generated)/mcp-servers",
+ "(generated)/skills",
+ "(generated)/custom-tools",
+ "(generated)/credentials",
+ "---Execution and Usage---",
+ "(generated)/execution",
+ "(generated)/human-in-the-loop",
+ "(generated)/usage"
]
}
diff --git a/apps/docs/lib/openapi.ts b/apps/docs/lib/openapi.ts
index af5f7a2b4c8..c3dac25a837 100644
--- a/apps/docs/lib/openapi.ts
+++ b/apps/docs/lib/openapi.ts
@@ -2,8 +2,18 @@ import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { createOpenAPI } from 'fumadocs-openapi/server'
+const SPEC_FILES = [
+ 'openapi-core.json',
+ 'openapi-v2-logs.json',
+ 'openapi-v2-workflows.json',
+ 'openapi-v2-tables.json',
+ 'openapi-v2-knowledge.json',
+ 'openapi-v2-files-audit.json',
+ 'openapi-v2-resources.json',
+] as const
+
export const openapi = createOpenAPI({
- input: ['./openapi.json'],
+ input: SPEC_FILES.map((file) => `./${file}`),
})
interface OpenAPIOperation {
@@ -24,20 +34,34 @@ function resolveRef(ref: string, spec: Record): unknown {
return current
}
-function resolveRefs(obj: unknown, spec: Record, depth = 0): unknown {
- if (depth > 10) return obj
+function resolveRefs(
+ obj: unknown,
+ spec: Record,
+ seen: Set = new Set(),
+ depth = 0
+): unknown {
+ // Generous backstop against pathological fan-out; real schemas nest far shallower.
+ if (depth > 50) return obj
if (Array.isArray(obj)) {
- return obj.map((item) => resolveRefs(item, spec, depth + 1))
+ return obj.map((item) => resolveRefs(item, spec, seen, depth + 1))
}
if (obj && typeof obj === 'object') {
const record = obj as Record
- if ('$ref' in record && typeof record.$ref === 'string') {
- const resolved = resolveRef(record.$ref, spec)
- return resolveRefs(resolved, spec, depth + 1)
+ if (typeof record.$ref === 'string') {
+ const ref = record.$ref
+ // Break reference cycles: if this $ref is already being expanded above us,
+ // leave it untouched instead of recursing forever.
+ if (seen.has(ref)) return record
+ const resolved = resolveRef(ref, spec)
+ if (resolved === undefined) return record
+ seen.add(ref)
+ const out = resolveRefs(resolved, spec, seen, depth + 1)
+ seen.delete(ref)
+ return out
}
const result: Record = {}
for (const [key, value] of Object.entries(record)) {
- result[key] = resolveRefs(value, spec, depth + 1)
+ result[key] = resolveRefs(value, spec, seen, depth + 1)
}
return result
}
@@ -48,14 +72,34 @@ function formatSchema(schema: unknown): string {
return JSON.stringify(schema, null, 2)
}
-let cachedSpec: Record | null = null
+let cachedSpecs: Record[] | null = null
+
+function getSpecs(): Record[] {
+ if (!cachedSpecs) {
+ cachedSpecs = SPEC_FILES.map(
+ (file) =>
+ JSON.parse(readFileSync(join(process.cwd(), file), 'utf8')) as Record
+ )
+ }
+ return cachedSpecs
+}
-function getSpec(): Record {
- if (!cachedSpec) {
- const specPath = join(process.cwd(), 'openapi.json')
- cachedSpec = JSON.parse(readFileSync(specPath, 'utf8')) as Record
+/**
+ * Locate an operation by path + method across every rendered spec, returning the
+ * operation together with the spec that owns it so `$ref`s resolve within the
+ * correct document (each spec carries its own `components`).
+ */
+function findOperation(
+ path: string,
+ method: string
+): { operation: Record; spec: Record } | undefined {
+ const key = method.toLowerCase()
+ for (const spec of getSpecs()) {
+ const pathObj = (spec.paths as Record> | undefined)?.[path]
+ const operation = pathObj?.[key] as Record | undefined
+ if (operation) return { operation, spec }
}
- return cachedSpec
+ return undefined
}
export function getApiSpecContent(
@@ -63,22 +107,19 @@ export function getApiSpecContent(
description: string | undefined,
operations: OpenAPIOperation[]
): string {
- const spec = getSpec()
-
if (!operations || operations.length === 0) {
return `# ${title}\n\n${description || ''}`
}
const op = operations[0]
const method = op.method.toUpperCase()
- const pathObj = (spec.paths as Record>)?.[op.path]
- const operation = pathObj?.[op.method.toLowerCase()] as Record | undefined
+ const found = findOperation(op.path, op.method)
- if (!operation) {
+ if (!found) {
return `# ${title}\n\n${description || ''}`
}
- const resolved = resolveRefs(operation, spec) as Record
+ const resolved = resolveRefs(found.operation, found.spec) as Record
const lines: string[] = []
lines.push(`# ${title}`)
diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json
new file mode 100644
index 00000000000..d5d3cccd20e
--- /dev/null
+++ b/apps/docs/openapi-core.json
@@ -0,0 +1,2201 @@
+{
+ "openapi": "3.1.0",
+ "info": {
+ "title": "Sim API — Execution & Usage",
+ "description": "Run workflows, poll and cancel executions, resume Human-in-the-Loop pauses, and check usage limits.",
+ "version": "1.0.0",
+ "contact": {
+ "name": "Sim Support",
+ "email": "help@sim.ai",
+ "url": "https://www.sim.ai"
+ },
+ "license": {
+ "name": "Apache 2.0",
+ "url": "https://www.apache.org/licenses/LICENSE-2.0.html"
+ }
+ },
+ "servers": [
+ {
+ "url": "https://www.sim.ai",
+ "description": "Production"
+ }
+ ],
+ "tags": [
+ {
+ "name": "Execution",
+ "description": "Run workflows, poll execution status, and cancel runs"
+ },
+ {
+ "name": "Human in the Loop",
+ "description": "Manage paused workflow executions and resume them with input"
+ },
+ {
+ "name": "Usage",
+ "description": "Check rate limits and billing usage"
+ }
+ ],
+ "security": [
+ {
+ "apiKey": []
+ }
+ ],
+ "paths": {
+ "/api/workflows/{id}/execute": {
+ "post": {
+ "operationId": "executeWorkflow",
+ "summary": "Execute Workflow",
+ "description": "Execute a deployed workflow. Supports synchronous, asynchronous, and streaming modes. For async execution, the response includes a statusUrl you can poll for results.",
+ "tags": ["Execution"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/workflows/{id}/execute\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"input\": {\n \"key\": \"value\"\n }\n }'"
+ }
+ ],
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "description": "The unique identifier of the deployed workflow to execute.",
+ "schema": {
+ "type": "string",
+ "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"
+ }
+ }
+ ],
+ "requestBody": {
+ "description": "Execution configuration including input values and execution mode options.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "input": {
+ "type": "object",
+ "description": "Key-value pairs matching the workflow's defined input fields. Use the Get Workflow endpoint to discover available input fields.",
+ "additionalProperties": true
+ },
+ "triggerType": {
+ "type": "string",
+ "description": "How this execution was triggered. Defaults to api when called via the REST API. Recorded in execution logs for filtering."
+ },
+ "stream": {
+ "type": "boolean",
+ "description": "When true, returns results as Server-Sent Events (SSE) for real-time block-by-block output streaming."
+ },
+ "selectedOutputs": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "List of specific block IDs whose outputs to include in the response. When omitted, all block outputs are returned."
+ }
+ }
+ },
+ "example": {
+ "input": {
+ "query": "What is the weather in Tokyo?"
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Synchronous execution completed successfully.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ExecutionResult"
+ },
+ "example": {
+ "success": true,
+ "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74",
+ "output": {
+ "content": "The weather in Tokyo is sunny, 22°C."
+ },
+ "error": null,
+ "metadata": {
+ "startTime": "2026-01-15T10:30:00Z",
+ "endTime": "2026-01-15T10:30:01Z",
+ "duration": 1250
+ }
+ }
+ }
+ }
+ },
+ "202": {
+ "description": "Asynchronous execution has been queued. Poll the statusUrl for results.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AsyncExecutionResult"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ }
+ }
+ }
+ },
+ "/api/workflows/{id}/executions/{executionId}": {
+ "get": {
+ "operationId": "getWorkflowExecution",
+ "summary": "Get Execution Status",
+ "description": "Get the current status of a workflow execution. Returns the run's lifecycle state (`running`, `paused`, `completed`, `failed`, etc.), timing, error, and optionally per-block outputs. Designed for polling — works for any execution, including ones that pause and resume.",
+ "tags": ["Execution"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl \\\n \"https://www.sim.ai/api/workflows/{id}/executions/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ },
+ {
+ "id": "curl-with-outputs",
+ "label": "cURL (with block outputs)",
+ "lang": "bash",
+ "source": "curl \\\n \"https://www.sim.ai/api/workflows/{id}/executions/{executionId}?selectedOutputs=blockId,blockId.field&includeOutput=true\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "description": "The unique identifier of the workflow.",
+ "schema": {
+ "type": "string",
+ "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"
+ }
+ },
+ {
+ "name": "executionId",
+ "in": "path",
+ "required": true,
+ "description": "The unique identifier of the execution.",
+ "schema": {
+ "type": "string",
+ "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13"
+ }
+ },
+ {
+ "name": "includeOutput",
+ "in": "query",
+ "required": false,
+ "description": "When `true` and the execution has `status: completed`, include the workflow's final output in the response.",
+ "schema": {
+ "type": "string",
+ "enum": ["true", "false"]
+ }
+ },
+ {
+ "name": "selectedOutputs",
+ "in": "query",
+ "required": false,
+ "description": "Comma-separated block-output selectors. A bare `blockId` returns that block's full output; a dot-path like `blockId.field` or `blockId.nested.path` returns just that value. Results are returned in the `blockOutputs` map keyed by the selector string.",
+ "schema": {
+ "type": "string",
+ "example": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf,c1b90bce-8a82-42a5-b6a5-5762846c2eaf.waitDuration"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Execution status returned.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WorkflowExecutionStatus"
+ },
+ "examples": {
+ "completed": {
+ "summary": "Completed run",
+ "value": {
+ "executionId": "9254f1c9-5a11-4a12-91e3-8065293f3609",
+ "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7",
+ "status": "completed",
+ "trigger": "api",
+ "level": "info",
+ "startedAt": "2026-05-15T19:43:12.189Z",
+ "endedAt": "2026-05-15T19:45:45.224Z",
+ "totalDurationMs": 153035,
+ "paused": null,
+ "cost": {
+ "total": 0.005
+ },
+ "error": null,
+ "finalOutput": null,
+ "blockOutputs": null
+ }
+ },
+ "paused": {
+ "summary": "Currently paused run",
+ "value": {
+ "executionId": "772749f6-ee81-414c-a2c3-671549dd62b8",
+ "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7",
+ "status": "paused",
+ "trigger": "manual",
+ "level": "info",
+ "startedAt": "2026-05-15T22:25:57.178Z",
+ "endedAt": "2026-05-15T22:25:57.215Z",
+ "totalDurationMs": 1,
+ "paused": {
+ "pausedAt": "2026-05-15T22:25:57.216Z",
+ "resumeAt": "2026-05-16T18:25:57.200Z",
+ "pauseKind": "time",
+ "blockedOnBlockId": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf",
+ "pausedExecutionId": "438bf05b-bd3c-4011-b78e-b19c112eeb66",
+ "pausePointCount": 1,
+ "resumedCount": 0
+ },
+ "cost": {
+ "total": 0.005
+ },
+ "error": null,
+ "finalOutput": null,
+ "blockOutputs": null
+ }
+ },
+ "failed": {
+ "summary": "Failed run",
+ "value": {
+ "executionId": "3ccfdeed-a63c-4e86-98e2-8bec723bca52",
+ "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7",
+ "status": "failed",
+ "trigger": "api",
+ "level": "error",
+ "startedAt": "2026-05-15T22:24:50.991Z",
+ "endedAt": "2026-05-15T22:24:50.999Z",
+ "totalDurationMs": 2,
+ "paused": null,
+ "cost": {
+ "total": 0.005
+ },
+ "error": "Wait 1: Wait time exceeds maximum of 5 minutes; enable async mode to wait up to 30 days",
+ "finalOutput": null,
+ "blockOutputs": null
+ }
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ }
+ }
+ }
+ },
+ "/api/workflows/{id}/executions/{executionId}/cancel": {
+ "post": {
+ "operationId": "cancelExecution",
+ "summary": "Cancel Execution",
+ "description": "Cancel a running workflow execution. Only effective for executions that are still in progress.",
+ "tags": ["Execution"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/workflows/{id}/executions/{executionId}/cancel\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "description": "The unique identifier of the workflow.",
+ "schema": {
+ "type": "string",
+ "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"
+ }
+ },
+ {
+ "name": "executionId",
+ "in": "path",
+ "required": true,
+ "description": "The unique identifier of the execution to cancel.",
+ "schema": {
+ "type": "string",
+ "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Execution was successfully cancelled.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "description": "Whether the cancellation was successful."
+ },
+ "executionId": {
+ "type": "string",
+ "description": "The ID of the cancelled execution."
+ }
+ }
+ },
+ "example": {
+ "success": true,
+ "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ }
+ }
+ }
+ },
+ "/api/jobs/{jobId}": {
+ "get": {
+ "operationId": "getJobStatus",
+ "summary": "Get Job Status",
+ "description": "Poll the status of an asynchronous workflow execution. Use the jobId returned from the Execute Workflow endpoint when the execution is queued asynchronously.",
+ "tags": ["Execution"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/jobs/{jobId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "name": "jobId",
+ "in": "path",
+ "required": true,
+ "description": "The job identifier returned in the async execution response.",
+ "schema": {
+ "type": "string",
+ "example": "job_4a3b2c1d0e"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Current status of the job. When completed, includes the execution output.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/JobStatus"
+ },
+ "example": {
+ "success": true,
+ "taskId": "job_abc123",
+ "status": "completed",
+ "output": {
+ "content": "Done"
+ },
+ "metadata": {
+ "startTime": "2026-01-15T10:30:00Z"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ }
+ }
+ }
+ },
+ "/api/workflows/{id}/paused": {
+ "get": {
+ "operationId": "listPausedExecutions",
+ "summary": "List Paused Executions",
+ "description": "List all paused executions for a workflow. Workflows pause at Human in the Loop blocks and wait for input before continuing. Use this endpoint to discover which executions need attention.",
+ "tags": ["Human in the Loop"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/workflows/{id}/paused?status=paused\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "description": "The unique identifier of the workflow.",
+ "schema": {
+ "type": "string",
+ "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"
+ }
+ },
+ {
+ "name": "status",
+ "in": "query",
+ "required": false,
+ "description": "Filter paused executions by status.",
+ "schema": {
+ "type": "string",
+ "example": "paused"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "List of paused executions.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "pausedExecutions": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/PausedExecutionSummary"
+ }
+ }
+ }
+ },
+ "example": {
+ "pausedExecutions": [
+ {
+ "id": "pe_abc123",
+ "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36",
+ "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13",
+ "status": "paused",
+ "totalPauseCount": 1,
+ "resumedCount": 0,
+ "pausedAt": "2026-01-15T10:30:00Z",
+ "updatedAt": "2026-01-15T10:30:00Z",
+ "expiresAt": null,
+ "metadata": null,
+ "triggerIds": [],
+ "pausePoints": [
+ {
+ "contextId": "ctx_xyz789",
+ "blockId": "block_hitl_1",
+ "registeredAt": "2026-01-15T10:30:00Z",
+ "resumeStatus": "paused",
+ "snapshotReady": true,
+ "resumeLinks": {
+ "apiUrl": "https://www.sim.ai/api/resume/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13/ctx_xyz789",
+ "uiUrl": "https://www.sim.ai/resume/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13",
+ "contextId": "ctx_xyz789",
+ "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13",
+ "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"
+ },
+ "response": {
+ "displayData": {
+ "title": "Approval Required",
+ "message": "Please review this request"
+ },
+ "formFields": []
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ }
+ }
+ }
+ },
+ "/api/workflows/{id}/paused/{executionId}": {
+ "get": {
+ "operationId": "getPausedExecution",
+ "summary": "Get Paused Execution",
+ "description": "Get detailed information about a specific paused execution, including its pause points, execution snapshot, and resume queue. Use this to inspect the state before resuming.",
+ "tags": ["Human in the Loop"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/workflows/{id}/paused/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "description": "The unique identifier of the workflow.",
+ "schema": {
+ "type": "string",
+ "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"
+ }
+ },
+ {
+ "name": "executionId",
+ "in": "path",
+ "required": true,
+ "description": "The execution ID of the paused execution.",
+ "schema": {
+ "type": "string",
+ "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Paused execution details.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PausedExecutionDetail"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ }
+ }
+ }
+ },
+ "/api/resume/{workflowId}/{executionId}": {
+ "get": {
+ "operationId": "getPausedExecutionByResumePath",
+ "summary": "Get Paused Execution (Resume Path)",
+ "description": "Get detailed information about a specific paused execution using the resume URL path. Returns the same data as the workflow paused execution detail endpoint.",
+ "tags": ["Human in the Loop"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "name": "workflowId",
+ "in": "path",
+ "required": true,
+ "description": "The unique identifier of the workflow.",
+ "schema": {
+ "type": "string",
+ "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"
+ }
+ },
+ {
+ "name": "executionId",
+ "in": "path",
+ "required": true,
+ "description": "The execution ID of the paused execution.",
+ "schema": {
+ "type": "string",
+ "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Paused execution details.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PausedExecutionDetail"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "500": {
+ "description": "Internal server error.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string",
+ "description": "Human-readable error message."
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/resume/{workflowId}/{executionId}/{contextId}": {
+ "get": {
+ "operationId": "getPauseContext",
+ "summary": "Get Pause Context",
+ "description": "Get detailed information about a specific pause context within a paused execution. Returns the pause point details, resume queue state, and any active resume entry.",
+ "tags": ["Human in the Loop"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}/{contextId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "name": "workflowId",
+ "in": "path",
+ "required": true,
+ "description": "The unique identifier of the workflow.",
+ "schema": {
+ "type": "string",
+ "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"
+ }
+ },
+ {
+ "name": "executionId",
+ "in": "path",
+ "required": true,
+ "description": "The execution ID of the paused execution.",
+ "schema": {
+ "type": "string",
+ "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13"
+ }
+ },
+ {
+ "name": "contextId",
+ "in": "path",
+ "required": true,
+ "description": "The pause context ID to retrieve details for.",
+ "schema": {
+ "type": "string",
+ "example": "ctx_xyz789"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Pause context details.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PauseContextDetail"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ }
+ }
+ },
+ "post": {
+ "operationId": "resumeExecution",
+ "summary": "Resume Execution",
+ "description": "Resume a paused workflow execution by providing input for a specific pause context. The execution continues from where it paused, using the provided input. Supports synchronous, asynchronous, and streaming modes (determined by the original execution's configuration).",
+ "tags": ["Human in the Loop"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}/{contextId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"input\": {\n \"approved\": true,\n \"comment\": \"Looks good to me\"\n }\n }'"
+ }
+ ],
+ "parameters": [
+ {
+ "name": "workflowId",
+ "in": "path",
+ "required": true,
+ "description": "The unique identifier of the workflow.",
+ "schema": {
+ "type": "string",
+ "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"
+ }
+ },
+ {
+ "name": "executionId",
+ "in": "path",
+ "required": true,
+ "description": "The execution ID of the paused execution.",
+ "schema": {
+ "type": "string",
+ "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13"
+ }
+ },
+ {
+ "name": "contextId",
+ "in": "path",
+ "required": true,
+ "description": "The pause context ID to resume. Found in the pause point's contextId field or resumeLinks.",
+ "schema": {
+ "type": "string",
+ "example": "ctx_xyz789"
+ }
+ }
+ ],
+ "requestBody": {
+ "description": "Input data for the resumed execution. The structure depends on the workflow's Human in the Loop block configuration.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "input": {
+ "type": "object",
+ "description": "Key-value pairs to pass as input to the resumed execution. If omitted, the entire request body is used as input.",
+ "additionalProperties": true
+ }
+ }
+ },
+ "example": {
+ "input": {
+ "approved": true,
+ "comment": "Looks good to me"
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Resume execution completed synchronously, or resume was queued behind another in-progress resume.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/ResumeResult"
+ },
+ {
+ "type": "object",
+ "description": "Resume has been queued behind another in-progress resume.",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": ["queued"],
+ "description": "Indicates the resume is queued."
+ },
+ "executionId": {
+ "type": "string",
+ "description": "The execution ID assigned to this resume."
+ },
+ "queuePosition": {
+ "type": "integer",
+ "description": "Position in the resume queue."
+ },
+ "message": {
+ "type": "string",
+ "description": "Human-readable status message."
+ }
+ }
+ },
+ {
+ "type": "object",
+ "description": "Resume execution started (non-API-key callers). The execution runs asynchronously.",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": ["started"],
+ "description": "Indicates the resume execution has started."
+ },
+ "executionId": {
+ "type": "string",
+ "description": "The execution ID for the resumed workflow."
+ },
+ "message": {
+ "type": "string",
+ "description": "Human-readable status message."
+ }
+ }
+ }
+ ]
+ },
+ "examples": {
+ "sync": {
+ "summary": "Synchronous completion",
+ "value": {
+ "success": true,
+ "status": "completed",
+ "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58",
+ "output": {
+ "result": "Approved and processed"
+ },
+ "error": null,
+ "metadata": {
+ "duration": 850,
+ "startTime": "2026-01-15T10:35:00Z",
+ "endTime": "2026-01-15T10:35:01Z"
+ }
+ }
+ },
+ "queued": {
+ "summary": "Queued behind another resume",
+ "value": {
+ "status": "queued",
+ "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58",
+ "queuePosition": 2,
+ "message": "Resume queued. It will run after current resumes finish."
+ }
+ },
+ "started": {
+ "summary": "Execution started (fire and forget)",
+ "value": {
+ "status": "started",
+ "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58",
+ "message": "Resume execution started."
+ }
+ }
+ }
+ }
+ }
+ },
+ "202": {
+ "description": "Resume execution has been queued for asynchronous processing. Poll the statusUrl for results.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AsyncExecutionResult"
+ },
+ "example": {
+ "success": true,
+ "async": true,
+ "jobId": "job_4a3b2c1d0e",
+ "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58",
+ "message": "Resume execution queued",
+ "statusUrl": "https://www.sim.ai/api/jobs/job_4a3b2c1d0e"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "500": {
+ "description": "Internal server error.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string",
+ "description": "Human-readable error message."
+ }
+ }
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "Failed to queue the resume execution. Retry the request.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string",
+ "description": "Error message."
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/users/me/usage-limits": {
+ "get": {
+ "operationId": "getUsageLimits",
+ "summary": "Get Usage Limits",
+ "description": "Retrieve your current usage spending and storage consumption for the billing period.",
+ "tags": ["Usage"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/users/me/usage-limits\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Current usage and storage information.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UsageLimits"
+ },
+ "example": {
+ "success": true,
+ "usage": {
+ "currentPeriodCost": 12.5,
+ "limit": 100,
+ "plan": "pro"
+ },
+ "storage": {
+ "usedBytes": 5242880,
+ "limitBytes": 1073741824,
+ "percentUsed": 0.49
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "parameters": []
+ }
+ },
+ "/api/v2/billing/usage": {
+ "get": {
+ "operationId": "getUsageSummary",
+ "summary": "Get Usage Summary",
+ "description": "Current-billing-period usage with the per-source credit breakdown (`workflow`, `copilot`, `knowledge-base`, …) — monitor one source's consumption directly instead of estimating it by subtraction. All values are credits (1,000 credits = $5); dollar costs are not part of this surface.",
+ "tags": ["Usage"],
+ "security": [
+ {
+ "apiKey": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "workspaceId",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Restrict to one workspace. A workspace-scoped API key is always pinned to its own workspace; passing a different id returns 403."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The current billing period's usage summary.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": [
+ "period",
+ "totalCredits",
+ "bySourceCredits",
+ "limitCredits",
+ "plan"
+ ],
+ "properties": {
+ "period": {
+ "type": "object",
+ "required": ["start", "end"],
+ "properties": {
+ "start": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "end": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "totalCredits": {
+ "type": "number"
+ },
+ "bySourceCredits": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "number"
+ },
+ "description": "Credits consumed per usage source over the billing period."
+ },
+ "limitCredits": {
+ "type": "number"
+ },
+ "plan": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "example": {
+ "data": {
+ "period": {
+ "start": "2026-07-01T00:00:00.000Z",
+ "end": "2026-08-01T00:00:00.000Z"
+ },
+ "totalCredits": 512,
+ "bySourceCredits": {
+ "workflow": 380,
+ "copilot": 120,
+ "knowledge-base": 12
+ },
+ "limitCredits": 20000,
+ "plan": "pro"
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/V2BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/V2Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/V2Forbidden"
+ },
+ "429": {
+ "$ref": "#/components/responses/V2RateLimited"
+ }
+ }
+ }
+ },
+ "/api/v2/billing/usage/logs": {
+ "get": {
+ "operationId": "listUsageLogs",
+ "summary": "List Usage Logs",
+ "description": "Cursor-paged, credit-denominated ledger of the account's usage events. The per-source aggregate lives on `GET /api/v2/billing/usage`; this is the row-level detail. Page by passing `nextCursor` back as `cursor` and stop when it is null.",
+ "tags": ["Usage"],
+ "security": [
+ {
+ "apiKey": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "source",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Restrict to one usage source (e.g. `workflow`, `copilot`)."
+ },
+ {
+ "name": "workspaceId",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Restrict to one workspace. A workspace-scoped API key is always pinned to its own workspace; passing a different id returns 403."
+ },
+ {
+ "name": "period",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "enum": ["1d", "7d", "30d", "custom", "all"],
+ "default": "30d"
+ },
+ "description": "Relative window, `all`, or `custom` (requires `startDate`)."
+ },
+ {
+ "name": "startDate",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Start of a `custom` window. Any `Date`-parseable string."
+ },
+ {
+ "name": "endDate",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ },
+ "description": "End of a `custom` window; defaults to now."
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 50
+ }
+ },
+ {
+ "name": "cursor",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Opaque cursor from the previous page."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "A page of usage events.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data", "nextCursor"],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["id", "createdAt", "source", "workflowName", "creditCost"],
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "source": {
+ "type": "string"
+ },
+ "workflowName": {
+ "type": ["string", "null"],
+ "description": "Populated only when `source` is `workflow`."
+ },
+ "creditCost": {
+ "type": "number",
+ "description": "Apportioned so page rows sum exactly to the rounded page total; can be 0 for a sub-credit event."
+ }
+ }
+ }
+ },
+ "nextCursor": {
+ "type": ["string", "null"],
+ "description": "Opaque cursor for the next page, or null on the final page."
+ }
+ }
+ },
+ "example": {
+ "data": [
+ {
+ "id": "log_1",
+ "createdAt": "2026-07-29T18:04:11.000Z",
+ "source": "copilot",
+ "workflowName": null,
+ "creditCost": 12
+ }
+ ],
+ "nextCursor": null
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/V2BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/V2Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/V2Forbidden"
+ },
+ "429": {
+ "$ref": "#/components/responses/V2RateLimited"
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "securitySchemes": {
+ "apiKey": {
+ "type": "apiKey",
+ "in": "header",
+ "name": "X-API-Key",
+ "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys."
+ }
+ },
+ "parameters": {
+ "TableId": {
+ "name": "tableId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14"
+ },
+ "description": "The unique identifier of the table."
+ },
+ "RowId": {
+ "name": "rowId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07"
+ },
+ "description": "The unique identifier of the row."
+ },
+ "WorkspaceId": {
+ "name": "workspaceId",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The unique identifier of the workspace."
+ }
+ },
+ "schemas": {
+ "ExecutionResult": {
+ "type": "object",
+ "description": "Result of a synchronous workflow execution.",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "description": "Whether the workflow executed successfully without errors.",
+ "example": true
+ },
+ "executionId": {
+ "type": "string",
+ "description": "Unique identifier for this execution. Use this to query logs or cancel the execution.",
+ "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13"
+ },
+ "output": {
+ "type": "object",
+ "description": "Workflow output keyed by block name and output field. Structure depends on the workflow's block configuration.",
+ "additionalProperties": true,
+ "example": {
+ "result": "Hello, world!"
+ }
+ },
+ "error": {
+ "type": "string",
+ "nullable": true,
+ "description": "Error message if the execution failed. null on success.",
+ "example": null
+ },
+ "metadata": {
+ "type": "object",
+ "description": "Execution timing metadata.",
+ "properties": {
+ "duration": {
+ "type": "integer",
+ "description": "Total execution duration in milliseconds.",
+ "example": 1250
+ },
+ "startTime": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when execution started.",
+ "example": "2025-06-20T14:15:22Z"
+ },
+ "endTime": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when execution completed.",
+ "example": "2025-06-20T14:15:23Z"
+ }
+ }
+ }
+ }
+ },
+ "AsyncExecutionResult": {
+ "type": "object",
+ "description": "Response returned when a workflow execution is queued for asynchronous processing.",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "description": "Whether the execution was successfully queued.",
+ "example": true
+ },
+ "async": {
+ "type": "boolean",
+ "description": "Always true for async executions. Use this to distinguish from synchronous responses.",
+ "example": true
+ },
+ "jobId": {
+ "type": "string",
+ "description": "Internal job queue identifier for tracking the execution.",
+ "example": "job_4a3b2c1d0e"
+ },
+ "executionId": {
+ "type": "string",
+ "description": "Unique execution identifier. Use this to query execution status or cancel.",
+ "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13"
+ },
+ "message": {
+ "type": "string",
+ "description": "Human-readable status message (e.g., \"Execution queued\").",
+ "example": "Execution queued"
+ },
+ "statusUrl": {
+ "type": "string",
+ "format": "uri",
+ "description": "URL to poll for execution status and results. Returns the full execution result once complete.",
+ "example": "https://www.sim.ai/api/jobs/job_4a3b2c1d0e"
+ }
+ }
+ },
+ "JobStatus": {
+ "type": "object",
+ "description": "Status of an asynchronous job.",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "description": "Whether the request was successful.",
+ "example": true
+ },
+ "taskId": {
+ "type": "string",
+ "description": "The unique identifier of the job.",
+ "example": "job_4a3b2c1d0e"
+ },
+ "status": {
+ "type": "string",
+ "enum": ["queued", "processing", "completed", "failed"],
+ "description": "Current status of the job.",
+ "example": "completed"
+ },
+ "metadata": {
+ "type": "object",
+ "description": "Timing metadata for the job.",
+ "properties": {
+ "startedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when the job started processing.",
+ "example": "2025-06-20T14:15:22Z"
+ },
+ "completedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when the job completed. Present only when status is completed or failed.",
+ "example": "2025-06-20T14:15:23Z"
+ },
+ "duration": {
+ "type": "integer",
+ "description": "Duration of the job in milliseconds. Present only when status is completed or failed.",
+ "example": 1250
+ }
+ }
+ },
+ "output": {
+ "description": "The workflow execution output. Present only when status is completed.",
+ "type": "object",
+ "example": {
+ "result": "Hello, world!"
+ }
+ },
+ "error": {
+ "description": "Error details. Present only when status is failed.",
+ "type": "string",
+ "example": null
+ },
+ "estimatedDuration": {
+ "type": "integer",
+ "description": "Estimated duration in milliseconds. Present only when status is queued or processing.",
+ "example": 2000
+ }
+ }
+ },
+ "WorkflowExecutionStatus": {
+ "type": "object",
+ "description": "Current status of a workflow execution.",
+ "properties": {
+ "executionId": {
+ "type": "string",
+ "description": "The unique identifier of the execution.",
+ "example": "9254f1c9-5a11-4a12-91e3-8065293f3609"
+ },
+ "workflowId": {
+ "type": "string",
+ "description": "The unique identifier of the workflow.",
+ "example": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7"
+ },
+ "status": {
+ "type": "string",
+ "enum": ["pending", "running", "paused", "completed", "failed", "cancelled"],
+ "description": "Current normalized lifecycle status. `paused` is set when a row exists in pausedExecutions with status `paused` or `partially_resumed`; otherwise the workflowExecutionLogs row's status field is used.",
+ "example": "completed"
+ },
+ "trigger": {
+ "type": "string",
+ "enum": ["api", "manual", "schedule", "webhook", "chat"],
+ "description": "What triggered the execution.",
+ "example": "api"
+ },
+ "level": {
+ "type": "string",
+ "enum": ["info", "warning", "error"],
+ "description": "Log level of the execution.",
+ "example": "info"
+ },
+ "startedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when execution started.",
+ "example": "2026-05-15T19:43:12.189Z"
+ },
+ "endedAt": {
+ "type": "string",
+ "format": "date-time",
+ "nullable": true,
+ "description": "ISO 8601 timestamp when execution ended. Null while the run is in flight.",
+ "example": "2026-05-15T19:45:45.224Z"
+ },
+ "totalDurationMs": {
+ "type": "integer",
+ "nullable": true,
+ "description": "Total duration of the execution in milliseconds. Null while the run is in flight.",
+ "example": 153035
+ },
+ "paused": {
+ "type": "object",
+ "nullable": true,
+ "description": "Pause-state details. Present only when status is `paused`.",
+ "properties": {
+ "pausedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when the workflow was paused.",
+ "example": "2026-05-15T22:25:57.216Z"
+ },
+ "resumeAt": {
+ "type": "string",
+ "format": "date-time",
+ "nullable": true,
+ "description": "Earliest scheduled resume time across active pause points. Null for human-only pauses.",
+ "example": "2026-05-16T18:25:57.200Z"
+ },
+ "pauseKind": {
+ "type": "string",
+ "enum": ["time", "human"],
+ "nullable": true,
+ "description": "What kind of pause the workflow is waiting on.",
+ "example": "time"
+ },
+ "blockedOnBlockId": {
+ "type": "string",
+ "nullable": true,
+ "description": "The block currently blocking resume.",
+ "example": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf"
+ },
+ "pausedExecutionId": {
+ "type": "string",
+ "description": "ID of the paused-execution row, useful for cross-referencing with the human-in-the-loop endpoints.",
+ "example": "438bf05b-bd3c-4011-b78e-b19c112eeb66"
+ },
+ "pausePointCount": {
+ "type": "integer",
+ "description": "Total number of pause points recorded for this execution.",
+ "example": 1
+ },
+ "resumedCount": {
+ "type": "integer",
+ "description": "Number of pause points already resumed.",
+ "example": 0
+ }
+ }
+ },
+ "cost": {
+ "type": "object",
+ "nullable": true,
+ "description": "Cost summary. Detailed token / model breakdown lives on the /v1/logs detail endpoint.",
+ "properties": {
+ "total": {
+ "type": "number",
+ "description": "Total cost in USD.",
+ "example": 0.005
+ }
+ }
+ },
+ "error": {
+ "type": "string",
+ "nullable": true,
+ "description": "Error message. Present only when status is `failed`.",
+ "example": null
+ },
+ "finalOutput": {
+ "type": "object",
+ "nullable": true,
+ "description": "The workflow's final output. Returned only when ?includeOutput=true AND status is `completed`.",
+ "example": null
+ },
+ "blockOutputs": {
+ "type": "object",
+ "nullable": true,
+ "description": "Per-block outputs keyed by the selector string. Returned only when `?selectedOutputs` is set.",
+ "additionalProperties": true,
+ "example": {
+ "c1b90bce-8a82-42a5-b6a5-5762846c2eaf.waitDuration": 60000,
+ "c1b90bce-8a82-42a5-b6a5-5762846c2eaf.status": "completed"
+ }
+ }
+ }
+ },
+ "UsageLimits": {
+ "type": "object",
+ "description": "Current usage and storage information for the authenticated user.",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "description": "Whether the request was successful."
+ },
+ "usage": {
+ "type": "object",
+ "description": "Current billing period usage.",
+ "properties": {
+ "currentPeriodCost": {
+ "type": "number",
+ "description": "Total spend in the current billing period in USD."
+ },
+ "limit": {
+ "type": "number",
+ "description": "Maximum allowed spend for the current billing period in USD."
+ },
+ "plan": {
+ "type": "string",
+ "description": "Your current subscription plan (e.g., free, pro, team)."
+ }
+ }
+ },
+ "storage": {
+ "type": "object",
+ "description": "File storage usage.",
+ "properties": {
+ "usedBytes": {
+ "type": "integer",
+ "description": "Total storage used in bytes."
+ },
+ "limitBytes": {
+ "type": "integer",
+ "description": "Maximum storage allowed in bytes."
+ },
+ "percentUsed": {
+ "type": "number",
+ "description": "Percentage of storage used (0-100)."
+ }
+ }
+ }
+ }
+ },
+ "PausedExecutionSummary": {
+ "type": "object",
+ "description": "Summary of a paused workflow execution.",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique identifier for the paused execution record."
+ },
+ "workflowId": {
+ "type": "string",
+ "description": "The workflow this execution belongs to."
+ },
+ "executionId": {
+ "type": "string",
+ "description": "The execution that was paused."
+ },
+ "status": {
+ "type": "string",
+ "description": "Current status of the paused execution.",
+ "example": "paused"
+ },
+ "totalPauseCount": {
+ "type": "integer",
+ "description": "Total number of pause points in this execution."
+ },
+ "resumedCount": {
+ "type": "integer",
+ "description": "Number of pause points that have been resumed."
+ },
+ "pausedAt": {
+ "type": "string",
+ "format": "date-time",
+ "nullable": true,
+ "description": "When the execution was paused."
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time",
+ "nullable": true,
+ "description": "When the paused execution record was last updated."
+ },
+ "expiresAt": {
+ "type": "string",
+ "format": "date-time",
+ "nullable": true,
+ "description": "When the paused execution will expire and be cleaned up."
+ },
+ "metadata": {
+ "type": "object",
+ "nullable": true,
+ "description": "Additional metadata associated with the paused execution.",
+ "additionalProperties": true
+ },
+ "triggerIds": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "IDs of triggers that initiated the original execution."
+ },
+ "pausePoints": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/PausePoint"
+ },
+ "description": "List of pause points in the execution."
+ }
+ }
+ },
+ "PausePoint": {
+ "type": "object",
+ "description": "A point in the workflow where execution has been paused awaiting human input.",
+ "properties": {
+ "contextId": {
+ "type": "string",
+ "description": "Unique identifier for this pause context. Used when resuming execution."
+ },
+ "blockId": {
+ "type": "string",
+ "description": "The block ID where execution paused."
+ },
+ "response": {
+ "description": "Data returned by the block before pausing, including display data and form fields."
+ },
+ "registeredAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "When this pause point was registered."
+ },
+ "resumeStatus": {
+ "type": "string",
+ "enum": ["paused", "resumed", "failed", "queued", "resuming"],
+ "description": "Current status of this pause point."
+ },
+ "snapshotReady": {
+ "type": "boolean",
+ "description": "Whether the execution snapshot is ready for resumption."
+ },
+ "resumeLinks": {
+ "type": "object",
+ "description": "Links for resuming this pause point.",
+ "properties": {
+ "apiUrl": {
+ "type": "string",
+ "format": "uri",
+ "description": "API endpoint URL to POST resume input to."
+ },
+ "uiUrl": {
+ "type": "string",
+ "format": "uri",
+ "description": "UI URL for a human to review and approve."
+ },
+ "contextId": {
+ "type": "string",
+ "description": "The context ID for this pause point."
+ },
+ "executionId": {
+ "type": "string",
+ "description": "The execution ID."
+ },
+ "workflowId": {
+ "type": "string",
+ "description": "The workflow ID."
+ }
+ }
+ },
+ "queuePosition": {
+ "type": "integer",
+ "nullable": true,
+ "description": "Position in the resume queue, if queued."
+ },
+ "latestResumeEntry": {
+ "$ref": "#/components/schemas/ResumeQueueEntry",
+ "nullable": true,
+ "description": "The most recent resume queue entry for this pause point."
+ },
+ "parallelScope": {
+ "type": "object",
+ "description": "Scope information when the pause occurs inside a parallel branch.",
+ "properties": {
+ "parallelId": {
+ "type": "string",
+ "description": "Identifier of the parallel execution group."
+ },
+ "branchIndex": {
+ "type": "integer",
+ "description": "Index of the branch within the parallel group."
+ },
+ "branchTotal": {
+ "type": "integer",
+ "description": "Total number of branches in the parallel group."
+ }
+ }
+ },
+ "loopScope": {
+ "type": "object",
+ "description": "Scope information when the pause occurs inside a loop.",
+ "properties": {
+ "loopId": {
+ "type": "string",
+ "description": "Identifier of the loop."
+ },
+ "iteration": {
+ "type": "integer",
+ "description": "Current loop iteration number."
+ }
+ }
+ }
+ }
+ },
+ "ResumeQueueEntry": {
+ "type": "object",
+ "description": "An entry in the resume execution queue.",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique identifier for this queue entry."
+ },
+ "pausedExecutionId": {
+ "type": "string",
+ "description": "The paused execution this entry belongs to."
+ },
+ "parentExecutionId": {
+ "type": "string",
+ "description": "The original execution that was paused."
+ },
+ "newExecutionId": {
+ "type": "string",
+ "description": "The new execution ID created for the resume."
+ },
+ "contextId": {
+ "type": "string",
+ "description": "The pause context ID being resumed."
+ },
+ "resumeInput": {
+ "description": "The input provided when resuming."
+ },
+ "status": {
+ "type": "string",
+ "description": "Status of this queue entry (e.g., pending, claimed, completed, failed)."
+ },
+ "queuedAt": {
+ "type": "string",
+ "format": "date-time",
+ "nullable": true,
+ "description": "When the entry was added to the queue."
+ },
+ "claimedAt": {
+ "type": "string",
+ "format": "date-time",
+ "nullable": true,
+ "description": "When execution started processing this entry."
+ },
+ "completedAt": {
+ "type": "string",
+ "format": "date-time",
+ "nullable": true,
+ "description": "When execution completed."
+ },
+ "failureReason": {
+ "type": "string",
+ "nullable": true,
+ "description": "Reason for failure, if the resume failed."
+ }
+ }
+ },
+ "PausedExecutionDetail": {
+ "type": "object",
+ "description": "Detailed information about a paused execution, including the execution snapshot and resume queue.",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/PausedExecutionSummary"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "executionSnapshot": {
+ "type": "object",
+ "description": "Serialized execution state for resumption.",
+ "properties": {
+ "snapshot": {
+ "type": "string",
+ "description": "Serialized execution snapshot data."
+ },
+ "triggerIds": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Trigger IDs from the snapshot."
+ }
+ }
+ },
+ "queue": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ResumeQueueEntry"
+ },
+ "description": "Resume queue entries for this execution."
+ }
+ }
+ }
+ ]
+ },
+ "PauseContextDetail": {
+ "type": "object",
+ "description": "Detailed information about a specific pause context within a paused execution.",
+ "properties": {
+ "execution": {
+ "$ref": "#/components/schemas/PausedExecutionSummary",
+ "description": "Summary of the parent paused execution."
+ },
+ "pausePoint": {
+ "$ref": "#/components/schemas/PausePoint",
+ "description": "The specific pause point for this context."
+ },
+ "queue": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ResumeQueueEntry"
+ },
+ "description": "Resume queue entries for this context."
+ },
+ "activeResumeEntry": {
+ "$ref": "#/components/schemas/ResumeQueueEntry",
+ "nullable": true,
+ "description": "The currently active resume entry, if any."
+ }
+ }
+ },
+ "ResumeResult": {
+ "type": "object",
+ "description": "Result of a synchronous resume execution.",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "description": "Whether the resume execution completed successfully."
+ },
+ "status": {
+ "type": "string",
+ "description": "Execution status.",
+ "enum": ["completed", "failed", "paused", "cancelled"],
+ "example": "completed"
+ },
+ "executionId": {
+ "type": "string",
+ "description": "The new execution ID for the resumed workflow."
+ },
+ "output": {
+ "type": "object",
+ "description": "Workflow output from the resumed execution.",
+ "additionalProperties": true
+ },
+ "error": {
+ "type": "string",
+ "nullable": true,
+ "description": "Error message if the execution failed."
+ },
+ "metadata": {
+ "type": "object",
+ "description": "Execution timing metadata.",
+ "properties": {
+ "duration": {
+ "type": "integer",
+ "description": "Total execution duration in milliseconds."
+ },
+ "startTime": {
+ "type": "string",
+ "format": "date-time",
+ "description": "When the resume execution started."
+ },
+ "endTime": {
+ "type": "string",
+ "format": "date-time",
+ "description": "When the resume execution completed."
+ }
+ }
+ }
+ }
+ },
+ "V2Error": {
+ "type": "object",
+ "required": ["error"],
+ "properties": {
+ "error": {
+ "type": "object",
+ "required": ["code", "message"],
+ "properties": {
+ "code": {
+ "type": "string",
+ "description": "Stable machine-readable code, e.g. `BAD_REQUEST`, `FORBIDDEN`, `RATE_LIMITED`."
+ },
+ "message": {
+ "type": "string"
+ },
+ "details": {
+ "description": "Optional structured context (e.g. per-field validation issues)."
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "BadRequest": {
+ "description": "Invalid request parameters. Check the details array for specific validation errors.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string",
+ "description": "Human-readable error message describing the validation failure."
+ },
+ "details": {
+ "type": "array",
+ "description": "List of specific validation errors with field-level details.",
+ "items": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "Unauthorized": {
+ "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string",
+ "description": "Human-readable error message."
+ }
+ }
+ }
+ }
+ }
+ },
+ "Forbidden": {
+ "description": "Access denied. You do not have permission to access this resource. For audit log endpoints, this requires an Enterprise subscription and organization admin/owner role.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string",
+ "description": "Human-readable error message."
+ }
+ }
+ }
+ }
+ }
+ },
+ "NotFound": {
+ "description": "The requested resource was not found. Verify the ID is correct and belongs to your workspace.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string",
+ "description": "Human-readable error message."
+ }
+ }
+ }
+ }
+ }
+ },
+ "RateLimited": {
+ "description": "Rate limit exceeded. Wait for the duration specified in the Retry-After header before retrying.",
+ "headers": {
+ "Retry-After": {
+ "description": "Number of seconds to wait before retrying the request.",
+ "schema": {
+ "type": "integer"
+ }
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string",
+ "description": "Human-readable error message with rate limit details."
+ }
+ }
+ }
+ }
+ }
+ },
+ "RowsUpdated": {
+ "description": "Rows updated.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "description": "Indicates whether the request was successful."
+ },
+ "data": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string",
+ "description": "Confirmation message describing how many rows were updated."
+ },
+ "updatedCount": {
+ "type": "integer",
+ "description": "Number of rows that were updated."
+ },
+ "updatedRowIds": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Array of IDs for each row that was updated."
+ }
+ },
+ "description": "Response payload."
+ }
+ }
+ },
+ "example": {
+ "success": true,
+ "data": {
+ "message": "Rows updated successfully",
+ "updatedCount": 2,
+ "updatedRowIds": [
+ "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93",
+ "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85"
+ ]
+ }
+ }
+ }
+ }
+ },
+ "V2BadRequest": {
+ "description": "Invalid request.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2Error"
+ }
+ }
+ }
+ },
+ "V2Unauthorized": {
+ "description": "Missing or invalid API key.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2Error"
+ }
+ }
+ }
+ },
+ "V2Forbidden": {
+ "description": "The credential is not authorized for the requested resource.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2Error"
+ }
+ }
+ }
+ },
+ "V2RateLimited": {
+ "description": "Rate limit exceeded; retry after the window resets.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2Error"
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json
new file mode 100644
index 00000000000..5b314f60a37
--- /dev/null
+++ b/apps/docs/openapi-v2-files-audit.json
@@ -0,0 +1,2567 @@
+{
+ "openapi": "3.1.0",
+ "info": {
+ "title": "Sim API v2 — Files & Audit Logs",
+ "description": "Version 2 of the Sim REST API for the Files and Audit Logs surfaces.\n\n## Conventions (v2)\n\nEvery v2 endpoint shares one response family:\n\n- **Single resource:** `{ \"data\": T }`\n- **List:** `{ \"data\": T[], \"nextCursor\": string | null }`\n- **Error:** `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`\n\n### Cursor pagination\n\nLists use an opaque keyset cursor (Stripe/Slack-style): pass `limit` and `cursor` in, receive `data` and `nextCursor` out. Treat `cursor` as opaque — pass back the `nextCursor` from the previous page verbatim. When `nextCursor` is `null` there are no more results. Total counts are not returned on lists.\n\n### Rate limiting\n\nRate-limit state is carried in response headers, not the body: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (an ISO 8601 timestamp). A throttled request returns `429` with a `Retry-After` header (seconds).\n\n### Authentication\n\nAll endpoints authenticate with the `X-API-Key` header (a personal or workspace API key). Files endpoints are workspace-scoped via the required `workspaceId` query parameter. Audit Logs endpoints are organization-scoped enterprise endpoints and require an Enterprise subscription plus an organization admin or owner role.",
+ "version": "2.0.0",
+ "contact": {
+ "name": "Sim Support",
+ "email": "help@sim.ai",
+ "url": "https://www.sim.ai"
+ },
+ "license": {
+ "name": "Apache 2.0",
+ "url": "https://www.apache.org/licenses/LICENSE-2.0.html"
+ }
+ },
+ "servers": [
+ {
+ "url": "https://www.sim.ai",
+ "description": "Production"
+ }
+ ],
+ "tags": [
+ {
+ "name": "Files",
+ "description": "Upload, download, list, rename, delete, share, and replace the contents of workspace files (v2). Workspace-scoped via the required workspaceId query parameter or body field."
+ },
+ {
+ "name": "Audit Logs",
+ "description": "Query the organization audit trail (v2). Organization-scoped enterprise endpoints requiring an Enterprise subscription and an organization admin or owner role."
+ }
+ ],
+ "security": [
+ {
+ "apiKey": []
+ }
+ ],
+ "paths": {
+ "/api/v2/files": {
+ "get": {
+ "operationId": "listFiles",
+ "summary": "List Files",
+ "description": "List a workspace's files with opaque cursor pagination. Pass the `nextCursor` from a previous response to fetch the next page; a `null` `nextCursor` means there are no more results. Use `folderPath` to return only files directly inside one canonical folder path; omit it to list files from every folder.",
+ "tags": ["Files"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/files?workspaceId=YOUR_WORKSPACE_ID&limit=100\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "description": "Maximum number of files to return per page. Clamped to the range 1–1000. Defaults to 100.",
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 1000,
+ "default": 100
+ }
+ },
+ {
+ "$ref": "#/components/parameters/Cursor"
+ },
+ {
+ "name": "folderPath",
+ "in": "query",
+ "required": false,
+ "description": "Restrict the list to one folder. Omit to list every file in the workspace.",
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ }
+ },
+ {
+ "name": "search",
+ "in": "query",
+ "required": false,
+ "description": "Case-insensitive substring match against the file `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 200
+ }
+ },
+ {
+ "name": "sortBy",
+ "in": "query",
+ "required": false,
+ "description": "Field to sort by. The cursor is a keyset over the active sort, so it carries the sort it was minted under. Replaying a cursor after changing `sortBy` or `sortOrder` returns `400`; restart pagination without a cursor instead.",
+ "schema": {
+ "type": "string",
+ "enum": ["name", "size", "uploadedAt", "updatedAt"],
+ "default": "uploadedAt"
+ }
+ },
+ {
+ "name": "sortOrder",
+ "in": "query",
+ "required": false,
+ "description": "Sort direction. The cursor is a keyset over the active sort, so it carries the sort it was minted under. Replaying a cursor after changing `sortBy` or `sortOrder` returns `400`; restart pagination without a cursor instead.",
+ "schema": {
+ "type": "string",
+ "enum": ["asc", "desc"],
+ "default": "asc"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "A page of workspace files.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2FileListResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "post": {
+ "operationId": "createFile",
+ "summary": "Create File",
+ "description": "Create an authored workspace file, either empty or with initial inline content. Use this endpoint for files whose bytes are already available as UTF-8 text or base64 and are at most 50 MiB after decoding. Use the upload-session endpoints for streamed or larger files. A live file with the same name in the same folder is rejected with `409`.",
+ "tags": ["Files"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/files\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"name\": \"notes.md\"}'"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["workspaceId", "name"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Workspace in which to create the file."
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255,
+ "description": "File name, including its extension. Path separators and dot segments are rejected."
+ },
+ "contentType": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255,
+ "description": "MIME type. When omitted, it is inferred from the file extension."
+ },
+ "folderPath": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128,
+ "description": "Canonical containing-folder path. `/` is the workspace root."
+ },
+ "content": {
+ "type": "string",
+ "maxLength": 70000000,
+ "default": "",
+ "description": "Initial file content. Omit or send an empty string to create a zero-byte file."
+ },
+ "encoding": {
+ "type": "string",
+ "enum": ["utf-8", "base64"],
+ "default": "utf-8",
+ "description": "Encoding of `content`."
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "The created file.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2FileResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "413": {
+ "$ref": "#/components/responses/PayloadTooLarge"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/files/uploads": {
+ "post": {
+ "operationId": "createFileUpload",
+ "summary": "Create File Upload",
+ "description": "Create an upload session and signed control token. Empty files and files up to and including 50 MiB receive a single signed PUT URL; larger files receive multipart transfer instructions. The maximum file size is 5 GB.",
+ "tags": ["Files"],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "The upload session.",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/files/uploads/{uploadId}": {
+ "delete": {
+ "operationId": "abortFileUpload",
+ "summary": "Abort File Upload",
+ "description": "Abort an incomplete upload and discard its provider parts.",
+ "tags": ["Files"],
+ "parameters": [
+ {
+ "name": "uploadId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ },
+ {
+ "$ref": "#/components/parameters/UploadTokenHeader"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The aborted upload session.",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/files/uploads/{uploadId}/parts": {
+ "post": {
+ "operationId": "createFileUploadPartUrls",
+ "summary": "Create File Upload Part URLs",
+ "description": "Issue short-lived signed PUT URLs for a bounded set of upload part numbers.",
+ "tags": ["Files"],
+ "parameters": [
+ {
+ "name": "uploadId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ },
+ {
+ "$ref": "#/components/parameters/UploadTokenHeader"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Signed URLs for the requested parts.",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/files/uploads/{uploadId}/complete": {
+ "post": {
+ "operationId": "completeFileUpload",
+ "summary": "Complete File Upload",
+ "description": "Verify the single PUT or assemble every multipart part, then atomically register the workspace file.",
+ "tags": ["Files"],
+ "parameters": [
+ {
+ "name": "uploadId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ },
+ {
+ "$ref": "#/components/parameters/UploadTokenHeader"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The completed upload and registered file.",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/files/{fileId}": {
+ "get": {
+ "operationId": "downloadFile",
+ "summary": "Download File",
+ "description": "Download the raw bytes of a file. The success response body is the file content itself — there is no JSON envelope. The actual `Content-Type` reflects the stored file's MIME type (shown here as `application/octet-stream`); `Content-Disposition` and `Content-Length` describe the attachment, and rate-limit state is returned in the `X-RateLimit-*` headers. Lookups are workspace-scoped: a file that belongs to another workspace returns `404`. Error responses still use the canonical v2 JSON error envelope.",
+ "tags": ["Files"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/files/{fileId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -o downloaded-file.csv"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/FileIdPath"
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The raw file content as binary data. The `Content-Type` header reflects the file's stored MIME type.",
+ "headers": {
+ "Content-Type": {
+ "description": "MIME type of the file. Varies per file; defaults to application/octet-stream when unknown.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ "Content-Disposition": {
+ "description": "Attachment disposition carrying the (sanitized and RFC 5987 encoded) filename.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ "Content-Length": {
+ "description": "Size of the file in bytes.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ },
+ "content": {
+ "application/octet-stream": {
+ "schema": {
+ "type": "string",
+ "format": "binary"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "delete": {
+ "operationId": "deleteFile",
+ "summary": "Delete File",
+ "description": "Delete a file in a workspace. The operation is workspace-scoped and records its own audit entry. Returns the file id and a `deleted` acknowledgement.",
+ "tags": ["Files"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/files/{fileId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/FileIdPath"
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The file was deleted.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2DeleteFileResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "description": "The file could not be deleted because of a conflicting state.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2Error"
+ }
+ }
+ }
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "patch": {
+ "operationId": "renameFile",
+ "summary": "Rename File",
+ "description": "Rename a file. Renaming only — use `POST /api/v2/files/move` to change which folder a file lives in. A name already taken in the same folder is rejected with `409`.",
+ "tags": ["Files"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X PATCH \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"name\": \"renamed.csv\"}'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/FileIdPath"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["workspaceId", "name"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "description": "The workspace that owns the file."
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255,
+ "description": "The new filename. Cannot contain `/`, `\\`, or be `.` / `..`."
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The renamed file.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2FileResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/files/{fileId}/metadata": {
+ "get": {
+ "operationId": "getFile",
+ "summary": "Get File Metadata",
+ "description": "Return one workspace file's metadata without downloading its content. Lookups are workspace-scoped: a file that belongs to another workspace returns `404`.",
+ "tags": ["Files"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/files/{fileId}/metadata?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/FileIdPath"
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The file metadata.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2FileResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/audit-logs": {
+ "get": {
+ "operationId": "listAuditLogs",
+ "summary": "List Audit Logs",
+ "description": "List audit log entries for the authenticated user's organization with opaque cursor pagination. These are organization-scoped (not workspace-scoped) enterprise endpoints: the caller must belong to an organization with an active Enterprise subscription and hold an admin or owner role — otherwise the request returns `403`. The `ipAddress` and `userAgent` fields are intentionally excluded from entries for privacy.",
+ "tags": ["Audit Logs"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/audit-logs?limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "name": "action",
+ "in": "query",
+ "required": false,
+ "description": "Filter by action type (e.g., file.uploaded, workflow.deployed, member.invited).",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "resourceType",
+ "in": "query",
+ "required": false,
+ "description": "Filter by resource type (e.g., file, workflow, workspace, member).",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "resourceId",
+ "in": "query",
+ "required": false,
+ "description": "Filter by a specific resource ID.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "workspaceId",
+ "in": "query",
+ "required": false,
+ "description": "Filter by a workspace within your organization. Must belong to your organization, otherwise the request returns 400.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "actorId",
+ "in": "query",
+ "required": false,
+ "description": "Filter by the user who performed the action. Must be a member of your organization, otherwise the request returns 400.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "startDate",
+ "in": "query",
+ "required": false,
+ "description": "Only return entries at or after this ISO 8601 timestamp.",
+ "schema": {
+ "type": "string",
+ "format": "date-time"
+ }
+ },
+ {
+ "name": "endDate",
+ "in": "query",
+ "required": false,
+ "description": "Only return entries at or before this ISO 8601 timestamp.",
+ "schema": {
+ "type": "string",
+ "format": "date-time"
+ }
+ },
+ {
+ "name": "includeDeparted",
+ "in": "query",
+ "required": false,
+ "description": "When true, include entries from users who have left the organization. Defaults to false.",
+ "schema": {
+ "type": "boolean",
+ "default": false
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "description": "Maximum number of entries to return per page. Must be between 1 and 100. Defaults to 50.",
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 50
+ }
+ },
+ {
+ "$ref": "#/components/parameters/Cursor"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "A page of audit log entries.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2AuditLogListResponse"
+ },
+ "example": {
+ "data": [
+ {
+ "id": "audit_2c3d4e5f6g",
+ "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
+ "actorId": "user_abc123",
+ "actorName": "Jane Smith",
+ "actorEmail": "jane@example.com",
+ "action": "file.uploaded",
+ "resourceType": "file",
+ "resourceId": "wf_V1StGXR8z5jdHi6BmyT91",
+ "resourceName": "data.csv",
+ "description": "Uploaded file \"data.csv\" via API",
+ "metadata": {
+ "fileSize": 1024,
+ "fileType": "text/csv"
+ },
+ "createdAt": "2026-01-15T10:30:00Z"
+ }
+ ],
+ "nextCursor": null
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "The request was malformed: an invalid query parameter, an `actorId` that is not a member of your organization, or a `workspaceId` that does not belong to your organization.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2Error"
+ },
+ "example": {
+ "error": {
+ "code": "BAD_REQUEST",
+ "message": "actorId is not a member of your organization"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/audit-logs/{id}": {
+ "get": {
+ "operationId": "getAuditLog",
+ "summary": "Get Audit Log",
+ "description": "Retrieve a single audit log entry by ID, scoped to the authenticated user's organization. Organization-scoped (not workspace-scoped): the caller must belong to an organization with an active Enterprise subscription and hold an admin or owner role — otherwise the request returns `403`. An entry outside your organization returns `404` (existence is not leaked). The `ipAddress` and `userAgent` fields are intentionally excluded for privacy.",
+ "tags": ["Audit Logs"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/audit-logs/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "description": "The unique audit log entry identifier.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "example": "audit_2c3d4e5f6g"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The audit log entry.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2AuditLogResponse"
+ },
+ "example": {
+ "data": {
+ "id": "audit_2c3d4e5f6g",
+ "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
+ "actorId": "user_abc123",
+ "actorName": "Jane Smith",
+ "actorEmail": "jane@example.com",
+ "action": "file.uploaded",
+ "resourceType": "file",
+ "resourceId": "wf_V1StGXR8z5jdHi6BmyT91",
+ "resourceName": "data.csv",
+ "description": "Uploaded file \"data.csv\" via API",
+ "metadata": {
+ "fileSize": 1024,
+ "fileType": "text/csv"
+ },
+ "createdAt": "2026-01-15T10:30:00Z"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/files/move": {
+ "post": {
+ "operationId": "moveFileItems",
+ "summary": "Move Files and Folders",
+ "description": "Move files and/or folders into a folder. `targetFolderPath: null` — or omitting it — moves the selection to the workspace root. At least one of `fileIds` or `folderPaths` must be non-empty. The whole selection moves under one lock, so a name collision at the destination fails the request with `409` instead of applying part of it.",
+ "tags": ["Files"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\\\n \"https://www.sim.ai/api/v2/files/move\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"fileIds\": [\"wf_V1StGXR8z5jdHi6BmyT91\"], \"targetFolderPath\": \"fold_9Kq2mZ7pR4tLxWc0Ye3Nu\"}'"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["workspaceId", "fileIds"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "description": "The workspace that owns the items."
+ },
+ "fileIds": {
+ "type": "array",
+ "maxItems": 1000,
+ "default": [],
+ "items": {
+ "type": "string"
+ },
+ "description": "Files to move."
+ },
+ "targetFolderPath": {
+ "type": "string",
+ "description": "Canonical destination folder path. Omit to use the workspace root."
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The items were moved.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2MoveFileItemsResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/files/{fileId}/share": {
+ "get": {
+ "operationId": "getFileShare",
+ "summary": "Get File Share",
+ "description": "Read a file's public share state. `share` is `null` when the file has never been shared. The encrypted password is never returned — `hasPassword` is the only password signal.\n\n**Disabling is not revoking.** Setting `isActive: false` preserves the token and the stored password / allow-list, so re-enabling later resurrects the identical URL. To make a link permanently unreachable, delete the file instead.",
+ "tags": ["Files"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/share?workspaceId=YOUR_WORKSPACE_ID\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/FileIdPath"
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The file's share state.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2GetFileShareResponse"
+ },
+ "example": {
+ "data": {
+ "share": {
+ "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb",
+ "token": "share-token-example",
+ "url": "https://www.sim.ai/f/share-token-example",
+ "isActive": true,
+ "resourceType": "file",
+ "resourceId": "wf_V1StGXR8z5jdHi6BmyT91",
+ "authType": "public",
+ "hasPassword": false,
+ "allowedEmails": []
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "put": {
+ "operationId": "upsertFileShare",
+ "summary": "Enable or Disable File Share",
+ "description": "Enable or disable a file's public share. Requires workspace `write`.\n\nThe share token is always server-generated; there is no way to supply one. `authType` selects how the link is gated: `public` (anyone with the link), `password` (requires `password` on first enable), or `email` / `sso` (requires a non-empty `allowedEmails`). Omitting `authType` on a re-enable keeps the stored mode, and the org access-control policy is evaluated against that stored mode rather than against `public`. Disabling is never blocked by the policy.\n\n**Disabling is not revoking.** Setting `isActive: false` preserves the token and the stored password / allow-list, so re-enabling later resurrects the identical URL. To make a link permanently unreachable, delete the file instead.",
+ "tags": ["Files"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X PUT \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/share\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"isActive\": true, \"authType\": \"public\"}'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/FileIdPath"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["workspaceId", "isActive"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "description": "The workspace that owns the file.",
+ "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64"
+ },
+ "isActive": {
+ "type": "boolean",
+ "description": "Whether the share should resolve. `false` disables without revoking."
+ },
+ "authType": {
+ "type": "string",
+ "enum": ["public", "password", "email", "sso"],
+ "description": "How the link is gated. Omit on a re-enable to keep the stored mode."
+ },
+ "password": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024,
+ "description": "Plaintext password for a `password` share. Required on first enable; omit to keep the stored one."
+ },
+ "allowedEmails": {
+ "type": "array",
+ "maxItems": 200,
+ "items": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 320
+ },
+ "description": "Allowed addresses or `@domain` patterns for an `email` / `sso` share. Must be non-empty when enabling one."
+ }
+ }
+ },
+ "examples": {
+ "publicLink": {
+ "summary": "Enable a public link",
+ "value": {
+ "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
+ "isActive": true,
+ "authType": "public"
+ }
+ },
+ "passwordProtected": {
+ "summary": "Enable a password-protected link",
+ "value": {
+ "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
+ "isActive": true,
+ "authType": "password",
+ "password": "EXAMPLE_PASSWORD"
+ }
+ },
+ "disable": {
+ "summary": "Disable (keeps the token and stored config)",
+ "value": {
+ "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
+ "isActive": false
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The share after the update.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2UpsertFileShareResponse"
+ },
+ "example": {
+ "data": {
+ "share": {
+ "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb",
+ "token": "share-token-example",
+ "url": "https://www.sim.ai/f/share-token-example",
+ "isActive": true,
+ "resourceType": "file",
+ "resourceId": "wf_V1StGXR8z5jdHi6BmyT91",
+ "authType": "public",
+ "hasPassword": false,
+ "allowedEmails": []
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/files/{fileId}/content": {
+ "put": {
+ "operationId": "updateFileContent",
+ "summary": "Replace File Content",
+ "description": "Replace a file's bytes. This is a full replace, not an append: `content` becomes the entire body of the file. Use `encoding: \"base64\"` for non-UTF-8 bytes. The decoded body is capped at 50MB and still debits the workspace storage quota, so a write that would push the payer past its limit fails with `413`.",
+ "tags": ["Files"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X PUT \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/content\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"content\": \"id,name\\\\n1,alpha\\\\n\"}'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/FileIdPath"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["workspaceId", "content"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "description": "The workspace that owns the file."
+ },
+ "content": {
+ "type": "string",
+ "description": "The file's new full contents, interpreted per `encoding`."
+ },
+ "encoding": {
+ "type": "string",
+ "enum": ["utf-8", "base64"],
+ "default": "utf-8",
+ "description": "How to decode `content` into bytes."
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The updated file.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2FileResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "413": {
+ "$ref": "#/components/responses/PayloadTooLarge"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/files/bulk-delete": {
+ "post": {
+ "operationId": "bulkDeleteFiles",
+ "summary": "Delete Files",
+ "description": "Delete up to 1,000 files. Folder deletion is available at `/api/v2/files/folders`.",
+ "tags": ["Files"],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["workspaceId", "fileIds"],
+ "properties": {
+ "workspaceId": {
+ "type": "string"
+ },
+ "fileIds": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 1000,
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Deletion result.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["deletedItems"],
+ "properties": {
+ "deletedItems": {
+ "type": "object",
+ "required": ["files"],
+ "properties": {
+ "files": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/files/folders": {
+ "get": {
+ "operationId": "listFilesFolders",
+ "summary": "List Folders",
+ "description": "List active folders for this resource. Omit `parentPath` for the full tree, or pass a canonical path (including `/`) for immediate children only.",
+ "tags": ["Files"],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ },
+ {
+ "name": "parentPath",
+ "in": "query",
+ "required": false,
+ "description": "Canonical parent path. `/` lists root folders; omit for every folder.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "search",
+ "in": "query",
+ "required": false,
+ "description": "Name search.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 200
+ }
+ },
+ {
+ "name": "sortBy",
+ "in": "query",
+ "required": false,
+ "description": "Sort field.",
+ "schema": {
+ "type": "string",
+ "enum": ["name", "createdAt", "updatedAt"],
+ "default": "name"
+ }
+ },
+ {
+ "name": "sortOrder",
+ "in": "query",
+ "required": false,
+ "description": "Sort direction.",
+ "schema": {
+ "type": "string",
+ "enum": ["asc", "desc"],
+ "default": "asc"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Folders.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data", "nextCursor"],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/FilesFolder"
+ }
+ },
+ "nextCursor": {
+ "type": ["string", "null"]
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "post": {
+ "operationId": "createFilesFolder",
+ "summary": "Create Folder",
+ "description": "Create exactly one folder leaf. Its parent path must already exist.",
+ "tags": ["Files"],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["workspaceId", "path"],
+ "properties": {
+ "workspaceId": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string",
+ "description": "Canonical non-root folder path."
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Folder.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["folder"],
+ "properties": {
+ "folder": {
+ "$ref": "#/components/schemas/FilesFolder"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "patch": {
+ "operationId": "relocateFilesFolder",
+ "summary": "Rename or Move Folder",
+ "description": "Rename, move, or rename and move a folder. Descendant paths change with the folder.",
+ "tags": ["Files"],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["workspaceId", "path", "destinationPath"],
+ "properties": {
+ "workspaceId": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string",
+ "description": "Current canonical non-root path."
+ },
+ "destinationPath": {
+ "type": "string",
+ "description": "New canonical non-root path."
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Folder.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["folder"],
+ "properties": {
+ "folder": {
+ "$ref": "#/components/schemas/FilesFolder"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "delete": {
+ "operationId": "deleteFilesFolder",
+ "summary": "Delete Folder",
+ "description": "Delete a folder. By default the folder must be empty. With `recursive=true`, its descendant folders and resources are deleted too.",
+ "tags": ["Files"],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ },
+ {
+ "name": "path",
+ "in": "query",
+ "required": true,
+ "description": "Canonical non-root folder path.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "recursive",
+ "in": "query",
+ "required": false,
+ "description": "Whether to delete the subtree.",
+ "schema": {
+ "type": "boolean",
+ "default": false
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Deletion result.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["path", "deleted", "deletedItems"],
+ "properties": {
+ "path": {
+ "type": "string"
+ },
+ "deleted": {
+ "type": "boolean",
+ "const": true
+ },
+ "deletedItems": {
+ "type": "object",
+ "required": ["folders", "files"],
+ "properties": {
+ "folders": {
+ "type": "integer"
+ },
+ "files": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "securitySchemes": {
+ "apiKey": {
+ "type": "apiKey",
+ "in": "header",
+ "name": "X-API-Key",
+ "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys."
+ }
+ },
+ "parameters": {
+ "WorkspaceIdQuery": {
+ "name": "workspaceId",
+ "in": "query",
+ "required": true,
+ "description": "The unique identifier of the workspace.",
+ "schema": {
+ "type": "string",
+ "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64"
+ }
+ },
+ "UploadTokenHeader": {
+ "name": "upload-token",
+ "in": "header",
+ "required": true,
+ "description": "The signed control token returned when the upload session was created.",
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ }
+ },
+ "FileIdPath": {
+ "name": "fileId",
+ "in": "path",
+ "required": true,
+ "description": "The unique identifier of the file.",
+ "schema": {
+ "type": "string",
+ "example": "wf_V1StGXR8z5jdHi6BmyT91"
+ }
+ },
+ "Cursor": {
+ "name": "cursor",
+ "in": "query",
+ "required": false,
+ "description": "Opaque pagination cursor. Pass the `nextCursor` value from a previous response to fetch the next page.",
+ "schema": {
+ "type": "string"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "description": "The maximum number of requests permitted in the current rate-limit window.",
+ "schema": {
+ "type": "integer",
+ "example": 100
+ }
+ },
+ "X-RateLimit-Remaining": {
+ "description": "The number of requests remaining in the current rate-limit window.",
+ "schema": {
+ "type": "integer",
+ "example": 95
+ }
+ },
+ "X-RateLimit-Reset": {
+ "description": "ISO 8601 timestamp at which the current rate-limit window resets.",
+ "schema": {
+ "type": "string",
+ "format": "date-time",
+ "example": "2026-01-15T11:00:00Z"
+ }
+ }
+ },
+ "schemas": {
+ "V2File": {
+ "type": "object",
+ "description": "A workspace file as exposed by the v2 surface.",
+ "required": [
+ "id",
+ "name",
+ "size",
+ "type",
+ "key",
+ "folderPath",
+ "folderPath",
+ "uploadedBy",
+ "uploadedAt",
+ "updatedAt"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique file identifier.",
+ "example": "wf_V1StGXR8z5jdHi6BmyT91"
+ },
+ "name": {
+ "type": "string",
+ "description": "Original filename.",
+ "example": "data.csv"
+ },
+ "size": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "File size in bytes.",
+ "example": 1024
+ },
+ "type": {
+ "type": "string",
+ "description": "MIME type of the file.",
+ "example": "text/csv"
+ },
+ "key": {
+ "type": "string",
+ "description": "Storage key for the file.",
+ "example": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv"
+ },
+ "uploadedBy": {
+ "type": "string",
+ "description": "User ID of the uploader.",
+ "example": "user_abc123"
+ },
+ "uploadedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 timestamp of when the file was uploaded.",
+ "example": "2026-01-15T10:30:00Z"
+ },
+ "folderPath": {
+ "type": "string",
+ "description": "Canonical containing-folder path. `/` is the workspace root.",
+ "example": "/Engineering"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 timestamp of the last write, content or metadata.",
+ "example": "2026-01-15T10:30:00Z"
+ }
+ }
+ },
+ "V2DeleteFileResult": {
+ "type": "object",
+ "description": "Acknowledgement returned by a successful delete.",
+ "required": ["id", "deleted"],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The unique identifier of the deleted file.",
+ "example": "wf_V1StGXR8z5jdHi6BmyT91"
+ },
+ "deleted": {
+ "type": "boolean",
+ "const": true,
+ "description": "Always true on a successful delete."
+ }
+ }
+ },
+ "V2AuditLogEntry": {
+ "type": "object",
+ "description": "A public enterprise audit log entry. The ipAddress and userAgent fields are intentionally excluded for privacy.",
+ "required": [
+ "id",
+ "workspaceId",
+ "actorId",
+ "actorName",
+ "actorEmail",
+ "action",
+ "resourceType",
+ "resourceId",
+ "resourceName",
+ "description",
+ "createdAt"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique identifier for the audit log entry.",
+ "example": "audit_2c3d4e5f6g"
+ },
+ "workspaceId": {
+ "type": ["string", "null"],
+ "description": "The workspace where the action occurred, or null for organization-level actions.",
+ "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64"
+ },
+ "actorId": {
+ "type": ["string", "null"],
+ "description": "The user ID of the person who performed the action, or null when not attributable.",
+ "example": "user_abc123"
+ },
+ "actorName": {
+ "type": ["string", "null"],
+ "description": "Display name of the person who performed the action.",
+ "example": "Jane Smith"
+ },
+ "actorEmail": {
+ "type": ["string", "null"],
+ "description": "Email address of the person who performed the action.",
+ "example": "jane@example.com"
+ },
+ "action": {
+ "type": "string",
+ "description": "The action that was performed (e.g., file.uploaded, workflow.deployed).",
+ "example": "file.uploaded"
+ },
+ "resourceType": {
+ "type": "string",
+ "description": "The type of resource affected (e.g., file, workflow, workspace, member).",
+ "example": "file"
+ },
+ "resourceId": {
+ "type": ["string", "null"],
+ "description": "The unique identifier of the affected resource.",
+ "example": "wf_V1StGXR8z5jdHi6BmyT91"
+ },
+ "resourceName": {
+ "type": ["string", "null"],
+ "description": "Display name of the affected resource.",
+ "example": "data.csv"
+ },
+ "description": {
+ "type": ["string", "null"],
+ "description": "Human-readable description of the action.",
+ "example": "Uploaded file \"data.csv\" via API"
+ },
+ "metadata": {
+ "description": "Arbitrary per-action metadata as JSON. The shape varies by action type and may be null for some actions.",
+ "example": {
+ "fileSize": 1024,
+ "fileType": "text/csv"
+ }
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when the action occurred.",
+ "example": "2026-01-15T10:30:00Z"
+ }
+ }
+ },
+ "V2FileListResponse": {
+ "type": "object",
+ "description": "A page of files plus the cursor for the next page.",
+ "required": ["data", "nextCursor"],
+ "properties": {
+ "data": {
+ "type": "array",
+ "description": "The files in this page.",
+ "items": {
+ "$ref": "#/components/schemas/V2File"
+ }
+ },
+ "nextCursor": {
+ "type": ["string", "null"],
+ "description": "Opaque cursor for the next page, or null when there are no more results."
+ }
+ }
+ },
+ "V2FileResponse": {
+ "type": "object",
+ "description": "A single file resource.",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/V2File"
+ }
+ }
+ },
+ "V2DeleteFileResponse": {
+ "type": "object",
+ "description": "The result of archiving a file.",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/V2DeleteFileResult"
+ }
+ }
+ },
+ "V2AuditLogListResponse": {
+ "type": "object",
+ "description": "A page of audit log entries plus the cursor for the next page.",
+ "required": ["data", "nextCursor"],
+ "properties": {
+ "data": {
+ "type": "array",
+ "description": "The audit log entries in this page.",
+ "items": {
+ "$ref": "#/components/schemas/V2AuditLogEntry"
+ }
+ },
+ "nextCursor": {
+ "type": ["string", "null"],
+ "description": "Opaque cursor for the next page, or null when there are no more results."
+ }
+ }
+ },
+ "V2AuditLogResponse": {
+ "type": "object",
+ "description": "A single audit log entry resource.",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/V2AuditLogEntry"
+ }
+ }
+ },
+ "V2Error": {
+ "type": "object",
+ "description": "The canonical v2 error envelope.",
+ "required": ["error"],
+ "properties": {
+ "error": {
+ "type": "object",
+ "required": ["code", "message"],
+ "properties": {
+ "code": {
+ "type": "string",
+ "description": "Stable, machine-readable error code (e.g., BAD_REQUEST, NOT_FOUND, RATE_LIMITED)."
+ },
+ "message": {
+ "type": "string",
+ "description": "Human-readable error message."
+ },
+ "details": {
+ "description": "Optional structured error context. For validation errors this is an array of field-level issues; for rate limiting it carries the reset timestamp."
+ }
+ }
+ }
+ }
+ },
+ "V2FileShare": {
+ "type": "object",
+ "description": "A file's public share. Never carries the storage key or the encrypted password — `hasPassword` is the only password signal exposed.",
+ "required": [
+ "id",
+ "token",
+ "url",
+ "isActive",
+ "resourceType",
+ "resourceId",
+ "authType",
+ "hasPassword",
+ "allowedEmails"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique share identifier.",
+ "example": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb"
+ },
+ "token": {
+ "type": "string",
+ "description": "The public token embedded in the share URL. Always server-generated.",
+ "example": "share-token-example"
+ },
+ "url": {
+ "type": "string",
+ "format": "uri",
+ "description": "The public share URL.",
+ "example": "https://www.sim.ai/f/share-token-example"
+ },
+ "isActive": {
+ "type": "boolean",
+ "description": "Whether the share currently resolves. Disabling does not revoke — see the endpoint description."
+ },
+ "resourceType": {
+ "type": "string",
+ "enum": ["file", "folder"],
+ "description": "The kind of resource shared. Always `file` on this surface."
+ },
+ "resourceId": {
+ "type": "string",
+ "description": "The shared resource id.",
+ "example": "wf_V1StGXR8z5jdHi6BmyT91"
+ },
+ "authType": {
+ "type": "string",
+ "enum": ["public", "password", "email", "sso"],
+ "description": "How the share is gated."
+ },
+ "hasPassword": {
+ "type": "boolean",
+ "description": "Whether a password is stored for this share."
+ },
+ "allowedEmails": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Allow-list of addresses or `@domain` patterns for `email`/`sso` shares. Empty otherwise."
+ }
+ }
+ },
+ "V2MoveFileItemsResult": {
+ "type": "object",
+ "description": "What the move actually relocated.",
+ "required": ["movedItems"],
+ "properties": {
+ "movedItems": {
+ "type": "object",
+ "required": ["files"],
+ "properties": {
+ "files": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ },
+ "V2GetFileShareResult": {
+ "type": "object",
+ "description": "The file's share state, or null when the file has never been shared.",
+ "required": ["share"],
+ "properties": {
+ "share": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/V2FileShare"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The share, or null when the file has never been shared."
+ }
+ }
+ },
+ "V2UpsertFileShareResult": {
+ "type": "object",
+ "description": "The share after the upsert.",
+ "required": ["share"],
+ "properties": {
+ "share": {
+ "$ref": "#/components/schemas/V2FileShare"
+ }
+ }
+ },
+ "V2MoveFileItemsResponse": {
+ "type": "object",
+ "description": "The result of a move.",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/V2MoveFileItemsResult"
+ }
+ }
+ },
+ "V2GetFileShareResponse": {
+ "type": "object",
+ "description": "The file's public share state.",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/V2GetFileShareResult"
+ }
+ }
+ },
+ "V2UpsertFileShareResponse": {
+ "type": "object",
+ "description": "The share after enabling or disabling it.",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/V2UpsertFileShareResult"
+ }
+ }
+ },
+ "FilesFolder": {
+ "type": "object",
+ "required": ["name", "path", "parentPath", "createdAt", "updatedAt"],
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Folder name."
+ },
+ "path": {
+ "type": "string",
+ "description": "Canonical folder path. This is the public folder identifier."
+ },
+ "parentPath": {
+ "type": "string",
+ "description": "Canonical parent path; `/` is the root."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ }
+ },
+ "responses": {
+ "BadRequest": {
+ "description": "Invalid request. Inspect `error.message` and the optional `error.details` for specifics.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2Error"
+ },
+ "example": {
+ "error": {
+ "code": "BAD_REQUEST",
+ "message": "Invalid request",
+ "details": [
+ {
+ "path": ["workspaceId"],
+ "code": "invalid_type",
+ "message": "Required"
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "Unauthorized": {
+ "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2Error"
+ },
+ "example": {
+ "error": {
+ "code": "UNAUTHORIZED",
+ "message": "Invalid API key"
+ }
+ }
+ }
+ }
+ },
+ "Forbidden": {
+ "description": "Access denied. For Files, the API key lacks access to the workspace. For Audit Logs, this requires an Enterprise subscription and an organization admin or owner role.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2Error"
+ },
+ "example": {
+ "error": {
+ "code": "FORBIDDEN",
+ "message": "Active enterprise subscription required"
+ }
+ }
+ }
+ }
+ },
+ "NotFound": {
+ "description": "The requested resource was not found, or it does not belong to the authorized scope.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2Error"
+ },
+ "example": {
+ "error": {
+ "code": "NOT_FOUND",
+ "message": "File not found"
+ }
+ }
+ }
+ }
+ },
+ "RateLimited": {
+ "description": "Rate limit exceeded. Wait for the duration in the Retry-After header before retrying.",
+ "headers": {
+ "Retry-After": {
+ "description": "Number of seconds to wait before retrying the request.",
+ "schema": {
+ "type": "integer",
+ "example": 30
+ }
+ },
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2Error"
+ },
+ "example": {
+ "error": {
+ "code": "RATE_LIMITED",
+ "message": "API rate limit exceeded",
+ "details": {
+ "retryAfter": "2026-01-15T11:00:00Z"
+ }
+ }
+ }
+ }
+ }
+ },
+ "InternalError": {
+ "description": "An unexpected error occurred on the server.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2Error"
+ },
+ "example": {
+ "error": {
+ "code": "INTERNAL_ERROR",
+ "message": "Internal server error"
+ }
+ }
+ }
+ }
+ },
+ "Conflict": {
+ "description": "The request conflicts with existing state — most often a name already taken in the destination folder.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2Error"
+ },
+ "example": {
+ "error": {
+ "code": "CONFLICT",
+ "message": "A file named \"data.csv\" already exists in this workspace"
+ }
+ }
+ }
+ }
+ },
+ "PayloadTooLarge": {
+ "description": "The body exceeds the per-request size limit, or accepting it would push the workspace past its storage quota.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2Error"
+ },
+ "example": {
+ "error": {
+ "code": "PAYLOAD_TOO_LARGE",
+ "message": "Storage limit exceeded"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json
new file mode 100644
index 00000000000..40276925c82
--- /dev/null
+++ b/apps/docs/openapi-v2-knowledge.json
@@ -0,0 +1,2889 @@
+{
+ "openapi": "3.1.0",
+ "info": {
+ "title": "Sim API v2 — Knowledge Bases",
+ "description": "The v2 Knowledge Bases API lets you create and manage knowledge bases, upload and inspect documents, and run vector and tag search over your indexed content.\n\n## Conventions\n\nAll endpoints live under the `/api/v2` base path and share a single set of conventions:\n\n- **Authentication** — Send your Sim API key in the `X-API-Key` header on every request. Keys are scoped to a workspace (or are personal keys that target a workspace); `workspaceId` is always required so the request can be tenant-scoped and rate-limited.\n- **Single-resource and mutation responses** return `{ \"data\": ... }`.\n- **List responses** use an opaque-cursor envelope: `{ \"data\": [ ... ], \"nextCursor\": string | null }`. Pass the returned `nextCursor` back as the `cursor` query parameter to fetch the next page. When `nextCursor` is `null` there are no more results. Cursors are opaque — do not parse or construct them.\n- **Errors** use a single envelope: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`. The HTTP status code and the stable `code` field move together (for example `404` ⇄ `NOT_FOUND`).\n- **Rate limiting** — Every response carries the current limiter state in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. A throttled request returns `429` with a `Retry-After` header.",
+ "version": "2.0.0",
+ "contact": {
+ "name": "Sim Support",
+ "email": "help@sim.ai",
+ "url": "https://www.sim.ai"
+ },
+ "license": {
+ "name": "Apache 2.0",
+ "url": "https://www.apache.org/licenses/LICENSE-2.0.html"
+ }
+ },
+ "servers": [
+ {
+ "url": "https://www.sim.ai",
+ "description": "Production"
+ }
+ ],
+ "tags": [
+ {
+ "name": "Knowledge Bases",
+ "description": "Create and manage knowledge bases, upload and inspect documents, and run vector and tag search (v2 API)."
+ }
+ ],
+ "security": [
+ {
+ "apiKey": []
+ }
+ ],
+ "paths": {
+ "/api/v2/knowledge": {
+ "get": {
+ "operationId": "listKnowledgeBases",
+ "summary": "List Knowledge Bases",
+ "description": "List all knowledge bases in a workspace. The full bounded per-workspace set is returned as a single page, so `nextCursor` is always `null` today; treat the response as a standard cursor list so pagination can be added later without a contract change.",
+ "tags": ["Knowledge Bases"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ },
+ {
+ "name": "folderPath",
+ "in": "query",
+ "required": false,
+ "description": "Restrict the list to one folder. Omit to list every knowledge base in the workspace.",
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ }
+ },
+ {
+ "name": "search",
+ "in": "query",
+ "required": false,
+ "description": "Case-insensitive substring match against the knowledge base `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 200
+ }
+ },
+ {
+ "name": "sortBy",
+ "in": "query",
+ "required": false,
+ "description": "Field to sort by.",
+ "schema": {
+ "type": "string",
+ "enum": ["name", "createdAt", "updatedAt"],
+ "default": "createdAt"
+ }
+ },
+ {
+ "name": "sortOrder",
+ "in": "query",
+ "required": false,
+ "description": "Sort direction.",
+ "schema": {
+ "type": "string",
+ "enum": ["asc", "desc"],
+ "default": "asc"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Knowledge bases for the workspace.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data", "nextCursor"],
+ "properties": {
+ "data": {
+ "type": "array",
+ "description": "The knowledge bases in the workspace.",
+ "items": {
+ "$ref": "#/components/schemas/KnowledgeBase"
+ }
+ },
+ "nextCursor": {
+ "$ref": "#/components/schemas/NextCursor"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "post": {
+ "operationId": "createKnowledgeBase",
+ "summary": "Create Knowledge Base",
+ "description": "Create a new knowledge base in a workspace. The embedding model and dimension are fixed server-side and cannot be supplied. Returns `201` with the created knowledge base.",
+ "tags": ["Knowledge Bases"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/knowledge\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"Product Documentation\",\n \"description\": \"All product docs and guides\"\n }'"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "The knowledge base to create.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateKnowledgeBaseBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "The knowledge base was created.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/KnowledgeBaseEnvelope"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "413": {
+ "$ref": "#/components/responses/PayloadTooLarge"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/knowledge/{id}": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/KnowledgeBaseId"
+ }
+ ],
+ "get": {
+ "operationId": "getKnowledgeBase",
+ "summary": "Get Knowledge Base",
+ "description": "Retrieve a single knowledge base by ID. A knowledge base that does not exist, belongs to another workspace, or that the caller cannot read is reported as `404` so cross-workspace existence is never leaked.",
+ "tags": ["Knowledge Bases"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge/{id}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The knowledge base.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/KnowledgeBaseEnvelope"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "put": {
+ "operationId": "updateKnowledgeBase",
+ "summary": "Update Knowledge Base",
+ "description": "Update a knowledge base's name, description, or chunking config. At least one of `name`, `description`, or `chunkingConfig` must be provided. The target workspace is carried in the request body.",
+ "tags": ["Knowledge Bases"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X PUT \\\n \"https://www.sim.ai/api/v2/knowledge/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"Updated name\"\n }'"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "The fields to update. At least one of name, description, or chunkingConfig is required.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateKnowledgeBaseBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The updated knowledge base.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/KnowledgeBaseEnvelope"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "delete": {
+ "operationId": "deleteKnowledgeBase",
+ "summary": "Delete Knowledge Base",
+ "description": "Delete a knowledge base and all of its documents. Returns a delete acknowledgement with the id of the removed knowledge base.",
+ "tags": ["Knowledge Bases"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/knowledge/{id}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The knowledge base was deleted.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DeleteEnvelope"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/knowledge/search": {
+ "post": {
+ "operationId": "searchKnowledge",
+ "summary": "Search Knowledge",
+ "description": "Run vector and/or tag search across one or more knowledge bases. Provide a `query` for semantic vector search, `tagFilters` for structured filtering, or both. At least one of `query` or `tagFilters` is required.\n\nNotes and limits:\n- Tag filters are only supported when searching a single knowledge base.\n- When a `query` is supplied, all targeted knowledge bases must use the same embedding model; otherwise the request is rejected. Search such knowledge bases separately.\n- A text query consumes hosted embedding (and optional rerank) usage; tag-only search is free.",
+ "tags": ["Knowledge Bases"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/knowledge/search\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"knowledgeBaseIds\": [\"KB_ID\"],\n \"query\": \"How do I reset my password?\",\n \"topK\": 10\n }'"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "The search request.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SearchBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Search results.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SearchEnvelope"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid request. Returned when neither `query` nor `tagFilters` is provided, when tag filters target more than one knowledge base, when the selected knowledge bases use different embedding models, or when a tag name/value is invalid.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "examples": {
+ "crossModel": {
+ "summary": "Knowledge bases use different embedding models",
+ "value": {
+ "error": {
+ "code": "BAD_REQUEST",
+ "message": "Selected knowledge bases use different embedding models and cannot be searched together. Search them separately."
+ }
+ }
+ },
+ "multiKbTagFilter": {
+ "summary": "Tag filters across multiple knowledge bases",
+ "value": {
+ "error": {
+ "code": "BAD_REQUEST",
+ "message": "Tag filters are only supported when searching a single knowledge base"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "402": {
+ "$ref": "#/components/responses/UsageLimitExceeded"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "description": "One or more of the requested knowledge bases do not exist or are not accessible from this workspace.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "NOT_FOUND",
+ "message": "Knowledge base not found or access denied"
+ }
+ }
+ }
+ }
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/knowledge/{id}/documents": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/KnowledgeBaseId"
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "get": {
+ "operationId": "listKnowledgeDocuments",
+ "summary": "List Documents",
+ "description": "List documents in a knowledge base. Supports search, enabled-state filtering, sorting, and cursor pagination. Pass the returned `nextCursor` back as `cursor` to fetch the next page; the total document count is available as `docCount` on the parent knowledge base.",
+ "tags": ["Knowledge Bases"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents?workspaceId=YOUR_WORKSPACE_ID&limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "description": "Maximum number of documents to return per page.",
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 50
+ }
+ },
+ {
+ "name": "cursor",
+ "in": "query",
+ "required": false,
+ "description": "Opaque pagination cursor from a previous response's `nextCursor`. Omit for the first page.",
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ }
+ },
+ {
+ "name": "search",
+ "in": "query",
+ "required": false,
+ "description": "Case-insensitive substring match against document filenames.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "enabledFilter",
+ "in": "query",
+ "required": false,
+ "description": "Filter documents by their enabled state.",
+ "schema": {
+ "type": "string",
+ "enum": ["all", "enabled", "disabled"],
+ "default": "all"
+ }
+ },
+ {
+ "name": "sortBy",
+ "in": "query",
+ "required": false,
+ "description": "Field to sort by.",
+ "schema": {
+ "type": "string",
+ "enum": [
+ "filename",
+ "fileSize",
+ "tokenCount",
+ "chunkCount",
+ "uploadedAt",
+ "processingStatus",
+ "enabled"
+ ],
+ "default": "uploadedAt"
+ }
+ },
+ {
+ "name": "sortOrder",
+ "in": "query",
+ "required": false,
+ "description": "Sort direction.",
+ "schema": {
+ "type": "string",
+ "enum": ["asc", "desc"],
+ "default": "desc"
+ }
+ },
+ {
+ "name": "workspaceId",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ },
+ "description": "Workspace that owns the knowledge base."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Documents in the knowledge base.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data", "nextCursor"],
+ "properties": {
+ "data": {
+ "type": "array",
+ "description": "The documents on this page.",
+ "items": {
+ "$ref": "#/components/schemas/DocumentSummary"
+ }
+ },
+ "nextCursor": {
+ "$ref": "#/components/schemas/NextCursor"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "post": {
+ "operationId": "uploadKnowledgeDocument",
+ "summary": "Upload Document",
+ "description": "Upload a single document to a knowledge base as `multipart/form-data`. The workspace is supplied as the `workspaceId` query parameter (not a form field) so authorization runs before the file body is buffered. The maximum file size is 100 MB. Processing is asynchronous: the document is returned with `processingStatus: \"pending\"` and indexing continues in the background — poll the Get Document endpoint to observe progress.",
+ "tags": ["Knowledge Bases"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -F \"file=@/path/to/document.pdf\""
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "The file to upload.",
+ "content": {
+ "multipart/form-data": {
+ "schema": {
+ "type": "object",
+ "required": ["file"],
+ "properties": {
+ "file": {
+ "type": "string",
+ "format": "binary",
+ "description": "The document file to upload (max 100 MB)."
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "The document was accepted and queued for processing.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DocumentSummaryEnvelope"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid request. Returned when the body is not valid multipart form data or the required `file` field is missing.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "BAD_REQUEST",
+ "message": "file form field is required"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "402": {
+ "$ref": "#/components/responses/UsageLimitExceeded"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "413": {
+ "description": "The uploaded file exceeds the 100 MB limit, or the workspace storage limit has been reached.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "PAYLOAD_TOO_LARGE",
+ "message": "File size exceeds 100MB limit (123.45MB)"
+ }
+ }
+ }
+ }
+ },
+ "415": {
+ "$ref": "#/components/responses/UnsupportedMediaType"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ },
+ "parameters": [
+ {
+ "name": "workspaceId",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ },
+ "description": "Workspace that owns the knowledge base."
+ }
+ ]
+ }
+ },
+ "/api/v2/knowledge/{id}/documents/uploads": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/KnowledgeBaseId"
+ }
+ ],
+ "post": {
+ "operationId": "createKnowledgeDocumentUpload",
+ "summary": "Create Document Upload",
+ "description": "Create an upload session for a knowledge document. Files up to and including 50 MiB use a single signed PUT; larger files use multipart transfer. Write access, billing, usage, file type, file size, and workspace storage are checked before provider storage is allocated. The signed upload token binds the caller, workspace, knowledge base, filename, content type, byte size, provider, and knowledge-document purpose. Files may be up to 100 MB.",
+ "tags": ["Knowledge Bases"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents/uploads\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"name\":\"guide.pdf\",\"contentType\":\"application/pdf\",\"size\":248913}'"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "Metadata for the document that will be uploaded through the returned transfer instructions.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateDocumentUploadBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "The terminal-safe upload session, signed control-plane token, and PUT or multipart transfer instructions.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateDocumentUploadEnvelope"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "402": {
+ "$ref": "#/components/responses/UsageLimitExceeded"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "413": {
+ "$ref": "#/components/responses/PayloadTooLarge"
+ },
+ "415": {
+ "$ref": "#/components/responses/UnsupportedMediaType"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/knowledge/{id}/documents/uploads/{uploadId}": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/KnowledgeBaseId"
+ },
+ {
+ "$ref": "#/components/parameters/UploadId"
+ },
+ {
+ "$ref": "#/components/parameters/UploadTokenHeader"
+ }
+ ],
+ "delete": {
+ "operationId": "abortKnowledgeDocumentUpload",
+ "summary": "Abort Document Upload",
+ "description": "Abort an incomplete knowledge-document upload and discard its provider parts. Aborting an already aborted session is safe.",
+ "tags": ["Knowledge Bases"],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The aborted upload session.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DocumentUploadEnvelope"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/knowledge/{id}/documents/uploads/{uploadId}/parts": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/KnowledgeBaseId"
+ },
+ {
+ "$ref": "#/components/parameters/UploadId"
+ },
+ {
+ "$ref": "#/components/parameters/UploadTokenHeader"
+ }
+ ],
+ "post": {
+ "operationId": "createKnowledgeDocumentUploadPartUrls",
+ "summary": "Create Document Upload Part URLs",
+ "description": "Issue short-lived signed PUT URLs for up to 100 part numbers. PUT each byte range directly to the returned URL with the returned headers.",
+ "tags": ["Knowledge Bases"],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreatePartUrlsBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Signed URLs for the requested parts.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PartUrlsEnvelope"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/knowledge/{id}/documents/uploads/{uploadId}/complete": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/KnowledgeBaseId"
+ },
+ {
+ "$ref": "#/components/parameters/UploadId"
+ },
+ {
+ "$ref": "#/components/parameters/UploadTokenHeader"
+ }
+ ],
+ "post": {
+ "operationId": "completeKnowledgeDocumentUpload",
+ "summary": "Complete Document Upload",
+ "description": "Verify the single PUT or assemble all multipart parts, record knowledge-base storage ownership, create the knowledge document, and queue asynchronous processing. Repeating the same completion is idempotent and returns the same document. It never registers a general workspace file.",
+ "tags": ["Knowledge Bases"],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The completed upload and queued knowledge document.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DocumentUploadEnvelope"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "402": {
+ "$ref": "#/components/responses/UsageLimitExceeded"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "413": {
+ "$ref": "#/components/responses/PayloadTooLarge"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/knowledge/{id}/documents/{documentId}": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/KnowledgeBaseId"
+ },
+ {
+ "$ref": "#/components/parameters/DocumentId"
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "get": {
+ "operationId": "getKnowledgeDocument",
+ "summary": "Get Document",
+ "description": "Retrieve the full detail for a single document, including processing state and connector provenance.",
+ "tags": ["Knowledge Bases"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents/{documentId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The document detail.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DocumentEnvelope"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ },
+ "parameters": [
+ {
+ "name": "workspaceId",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ },
+ "description": "Workspace that owns the knowledge base."
+ }
+ ]
+ },
+ "delete": {
+ "operationId": "deleteKnowledgeDocument",
+ "summary": "Delete Document",
+ "description": "Delete a single document from a knowledge base. Returns a delete acknowledgement with the id of the removed document.",
+ "tags": ["Knowledge Bases"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents/{documentId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The document was deleted.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DeleteEnvelope"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ },
+ "parameters": [
+ {
+ "name": "workspaceId",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ },
+ "description": "Workspace that owns the knowledge base."
+ }
+ ]
+ }
+ },
+ "/api/v2/knowledge/folders": {
+ "get": {
+ "operationId": "listKnowledgeFolders",
+ "summary": "List Folders",
+ "description": "List active folders for this resource. Omit `parentPath` for the full tree, or pass a canonical path (including `/`) for immediate children only.",
+ "tags": ["Knowledge Bases"],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ },
+ {
+ "name": "parentPath",
+ "in": "query",
+ "required": false,
+ "description": "Canonical parent path. `/` lists root folders; omit for every folder.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "search",
+ "in": "query",
+ "required": false,
+ "description": "Name search.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 200
+ }
+ },
+ {
+ "name": "sortBy",
+ "in": "query",
+ "required": false,
+ "description": "Sort field.",
+ "schema": {
+ "type": "string",
+ "enum": ["name", "createdAt", "updatedAt"],
+ "default": "name"
+ }
+ },
+ {
+ "name": "sortOrder",
+ "in": "query",
+ "required": false,
+ "description": "Sort direction.",
+ "schema": {
+ "type": "string",
+ "enum": ["asc", "desc"],
+ "default": "asc"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Folders.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data", "nextCursor"],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/KnowledgeFolder"
+ }
+ },
+ "nextCursor": {
+ "type": ["string", "null"]
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "post": {
+ "operationId": "createKnowledgeFolder",
+ "summary": "Create Folder",
+ "description": "Create exactly one folder leaf. Its parent path must already exist.",
+ "tags": ["Knowledge Bases"],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["workspaceId", "path"],
+ "properties": {
+ "workspaceId": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string",
+ "description": "Canonical non-root folder path."
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Folder.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["folder"],
+ "properties": {
+ "folder": {
+ "$ref": "#/components/schemas/KnowledgeFolder"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "patch": {
+ "operationId": "relocateKnowledgeFolder",
+ "summary": "Rename or Move Folder",
+ "description": "Rename, move, or rename and move a folder. Descendant paths change with the folder.",
+ "tags": ["Knowledge Bases"],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["workspaceId", "path", "destinationPath"],
+ "properties": {
+ "workspaceId": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string",
+ "description": "Current canonical non-root path."
+ },
+ "destinationPath": {
+ "type": "string",
+ "description": "New canonical non-root path."
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Folder.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["folder"],
+ "properties": {
+ "folder": {
+ "$ref": "#/components/schemas/KnowledgeFolder"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "delete": {
+ "operationId": "deleteKnowledgeFolder",
+ "summary": "Delete Folder",
+ "description": "Delete a folder. By default the folder must be empty. With `recursive=true`, its descendant folders and resources are deleted too.",
+ "tags": ["Knowledge Bases"],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ },
+ {
+ "name": "path",
+ "in": "query",
+ "required": true,
+ "description": "Canonical non-root folder path.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "recursive",
+ "in": "query",
+ "required": false,
+ "description": "Whether to delete the subtree.",
+ "schema": {
+ "type": "boolean",
+ "default": false
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Deletion result.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["path", "deleted", "deletedItems"],
+ "properties": {
+ "path": {
+ "type": "string"
+ },
+ "deleted": {
+ "type": "boolean",
+ "const": true
+ },
+ "deletedItems": {
+ "type": "object",
+ "required": ["folders", "knowledgeBases"],
+ "properties": {
+ "folders": {
+ "type": "integer"
+ },
+ "knowledgeBases": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "securitySchemes": {
+ "apiKey": {
+ "type": "apiKey",
+ "in": "header",
+ "name": "X-API-Key",
+ "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys."
+ }
+ },
+ "parameters": {
+ "KnowledgeBaseId": {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "description": "The unique identifier of the knowledge base.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
+ }
+ },
+ "DocumentId": {
+ "name": "documentId",
+ "in": "path",
+ "required": true,
+ "description": "The unique identifier of the document.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "example": "b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12"
+ }
+ },
+ "UploadId": {
+ "name": "uploadId",
+ "in": "path",
+ "required": true,
+ "description": "The upload session identifier returned when the upload was created.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "example": "upload_01K0M9J4W6K4J3T73Q8W2NYR9P"
+ }
+ },
+ "WorkspaceIdQuery": {
+ "name": "workspaceId",
+ "in": "query",
+ "required": true,
+ "description": "The unique identifier of the workspace that scopes the request.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64"
+ }
+ },
+ "UploadTokenHeader": {
+ "name": "upload-token",
+ "in": "header",
+ "required": true,
+ "description": "The signed token returned when this upload was created. It is bound to the caller and all upload metadata.",
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ }
+ }
+ },
+ "headers": {
+ "RateLimitLimit": {
+ "description": "The maximum number of requests permitted in the current rate-limit window.",
+ "schema": {
+ "type": "integer",
+ "example": 60
+ }
+ },
+ "RateLimitRemaining": {
+ "description": "The number of requests remaining in the current rate-limit window.",
+ "schema": {
+ "type": "integer",
+ "example": 59
+ }
+ },
+ "RateLimitReset": {
+ "description": "ISO 8601 timestamp at which the current rate-limit window resets.",
+ "schema": {
+ "type": "string",
+ "format": "date-time",
+ "example": "2025-06-20T14:16:00Z"
+ }
+ },
+ "RetryAfter": {
+ "description": "Number of seconds to wait before retrying the request.",
+ "schema": {
+ "type": "integer",
+ "example": 30
+ }
+ }
+ },
+ "schemas": {
+ "NextCursor": {
+ "type": ["string", "null"],
+ "description": "Opaque cursor for the next page, or null when there are no more results. Pass it back as the `cursor` query parameter. Do not parse or construct cursors.",
+ "example": null
+ },
+ "ChunkingConfig": {
+ "type": "object",
+ "description": "How documents in this knowledge base are split into chunks before embedding.",
+ "required": ["maxSize", "minSize", "overlap"],
+ "additionalProperties": true,
+ "properties": {
+ "maxSize": {
+ "type": "integer",
+ "description": "Maximum chunk size, in tokens.",
+ "example": 1024
+ },
+ "minSize": {
+ "type": "integer",
+ "description": "Minimum chunk size, in characters.",
+ "example": 100
+ },
+ "overlap": {
+ "type": "integer",
+ "description": "Number of overlapping characters between adjacent chunks.",
+ "example": 200
+ },
+ "strategy": {
+ "type": "string",
+ "description": "Chunking strategy applied during processing.",
+ "enum": ["auto", "text", "regex", "recursive", "sentence", "token"]
+ },
+ "strategyOptions": {
+ "type": "object",
+ "additionalProperties": false,
+ "description": "Strategy-specific tuning. `pattern`/`strictBoundaries` apply to the `regex` strategy; `separators` to `text`; `recipe` to `recursive`.",
+ "properties": {
+ "pattern": {
+ "type": "string",
+ "maxLength": 500
+ },
+ "separators": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "recipe": {
+ "enum": ["plain", "markdown", "code"]
+ },
+ "strictBoundaries": {
+ "type": "boolean"
+ }
+ }
+ }
+ }
+ },
+ "ChunkingConfigInput": {
+ "type": "object",
+ "description": "Chunking configuration for the knowledge base. Defaults are applied when omitted.",
+ "properties": {
+ "maxSize": {
+ "type": "integer",
+ "description": "Maximum chunk size, in tokens.",
+ "minimum": 100,
+ "maximum": 4000,
+ "default": 1024
+ },
+ "minSize": {
+ "type": "integer",
+ "description": "Minimum chunk size, in characters.",
+ "minimum": 1,
+ "maximum": 2000,
+ "default": 100
+ },
+ "overlap": {
+ "type": "integer",
+ "description": "Number of overlapping characters between adjacent chunks.",
+ "minimum": 0,
+ "maximum": 500,
+ "default": 200
+ }
+ }
+ },
+ "KnowledgeBase": {
+ "type": "object",
+ "description": "A knowledge base: a collection of documents indexed for vector and tag search.",
+ "required": [
+ "id",
+ "name",
+ "description",
+ "tokenCount",
+ "embeddingModel",
+ "embeddingDimension",
+ "chunkingConfig",
+ "createdAt",
+ "updatedAt",
+ "folderPath"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique knowledge base identifier.",
+ "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
+ },
+ "name": {
+ "type": "string",
+ "description": "Human-readable knowledge base name.",
+ "example": "Product Documentation"
+ },
+ "description": {
+ "type": ["string", "null"],
+ "description": "Optional description of the knowledge base. null when not set.",
+ "example": "All product docs and guides"
+ },
+ "tokenCount": {
+ "type": "integer",
+ "description": "Total number of tokens across all indexed documents.",
+ "example": 48213
+ },
+ "embeddingModel": {
+ "type": "string",
+ "description": "The embedding model used to index documents in this knowledge base.",
+ "example": "text-embedding-3-small"
+ },
+ "embeddingDimension": {
+ "type": "integer",
+ "description": "The dimensionality of the embedding vectors.",
+ "example": 1536
+ },
+ "chunkingConfig": {
+ "$ref": "#/components/schemas/ChunkingConfig"
+ },
+ "docCount": {
+ "type": "integer",
+ "description": "Number of documents in the knowledge base.",
+ "example": 12
+ },
+ "connectorTypes": {
+ "type": "array",
+ "description": "The set of external connector types that have synced documents into this knowledge base.",
+ "items": {
+ "type": "string"
+ },
+ "example": ["notion", "google_drive"]
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when the knowledge base was created.",
+ "example": "2025-01-10T09:00:00Z"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when the knowledge base was last modified.",
+ "example": "2025-06-18T16:45:00Z"
+ },
+ "folderPath": {
+ "type": "string",
+ "description": "Canonical containing-folder path. `/` is the workspace root."
+ }
+ }
+ },
+ "KnowledgeBaseEnvelope": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["knowledgeBase"],
+ "properties": {
+ "knowledgeBase": {
+ "$ref": "#/components/schemas/KnowledgeBase"
+ }
+ }
+ }
+ }
+ },
+ "CreateKnowledgeBaseBody": {
+ "type": "object",
+ "description": "Request body for creating a knowledge base.",
+ "required": ["workspaceId", "name"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace the knowledge base belongs to.",
+ "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64"
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255,
+ "description": "Human-readable knowledge base name.",
+ "example": "Product Documentation"
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 1000,
+ "description": "Optional description of the knowledge base.",
+ "example": "All product docs and guides"
+ },
+ "chunkingConfig": {
+ "$ref": "#/components/schemas/ChunkingConfigInput"
+ },
+ "folderPath": {
+ "type": "string",
+ "description": "Canonical containing-folder path. `/` is the workspace root."
+ }
+ }
+ },
+ "UpdateKnowledgeBaseBody": {
+ "type": "object",
+ "description": "Request body for updating a knowledge base. At least one of name, description, or chunkingConfig must be provided.",
+ "required": ["workspaceId"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace the knowledge base belongs to.",
+ "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64"
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255,
+ "description": "New knowledge base name.",
+ "example": "Updated Product Documentation"
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 1000,
+ "description": "New description of the knowledge base.",
+ "example": "Refreshed product docs and guides"
+ },
+ "chunkingConfig": {
+ "$ref": "#/components/schemas/ChunkingConfigInput"
+ },
+ "folderPath": {
+ "type": "string",
+ "description": "Canonical containing-folder path. `/` is the workspace root."
+ }
+ }
+ },
+ "CreateDocumentUploadBody": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["workspaceId", "name", "contentType", "size"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Workspace that owns the knowledge base."
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255,
+ "description": "Filename recorded on the knowledge document."
+ },
+ "contentType": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255,
+ "description": "Supported MIME type for the document."
+ },
+ "size": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 104857600,
+ "description": "Exact file size in bytes."
+ },
+ "tag1": {
+ "type": "string",
+ "maxLength": 1000
+ },
+ "tag2": {
+ "type": "string",
+ "maxLength": 1000
+ },
+ "tag3": {
+ "type": "string",
+ "maxLength": 1000
+ },
+ "tag4": {
+ "type": "string",
+ "maxLength": 1000
+ },
+ "tag5": {
+ "type": "string",
+ "maxLength": 1000
+ },
+ "tag6": {
+ "type": "string",
+ "maxLength": 1000
+ },
+ "tag7": {
+ "type": "string",
+ "maxLength": 1000
+ },
+ "processingOptions": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "recipe": {
+ "type": "string",
+ "maxLength": 255
+ },
+ "lang": {
+ "type": "string",
+ "maxLength": 35
+ }
+ },
+ "description": "Optional processing recipe and language, bound into the signed upload state."
+ }
+ }
+ },
+ "DocumentUpload": {
+ "type": "object",
+ "required": [
+ "id",
+ "knowledgeBaseId",
+ "status",
+ "name",
+ "contentType",
+ "size",
+ "expiresAt",
+ "error",
+ "document"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Upload session identifier."
+ },
+ "knowledgeBaseId": {
+ "type": "string",
+ "description": "Knowledge base that will own the document."
+ },
+ "status": {
+ "type": "string",
+ "enum": ["uploading", "finalizing", "completed", "failed", "aborted", "expired"]
+ },
+ "name": {
+ "type": "string"
+ },
+ "contentType": {
+ "type": "string"
+ },
+ "size": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "expiresAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "error": {
+ "type": ["string", "null"]
+ },
+ "document": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/DocumentSummary"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The queued document after completion; null while uploading or after abort."
+ }
+ }
+ },
+ "DocumentUploadEnvelope": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/DocumentUpload"
+ }
+ }
+ },
+ "PutUploadTransfer": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["method", "url", "headers"],
+ "properties": {
+ "method": {
+ "type": "string",
+ "const": "put"
+ },
+ "url": {
+ "type": "string",
+ "format": "uri"
+ },
+ "headers": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "MultipartUploadTransfer": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["method", "partSize", "partCount"],
+ "properties": {
+ "method": {
+ "type": "string",
+ "const": "multipart"
+ },
+ "partSize": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "partCount": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 640
+ }
+ }
+ },
+ "UploadTransfer": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/PutUploadTransfer"
+ },
+ {
+ "$ref": "#/components/schemas/MultipartUploadTransfer"
+ }
+ ],
+ "discriminator": {
+ "propertyName": "method"
+ }
+ },
+ "CreateDocumentUploadEnvelope": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["session", "uploadToken", "transfer"],
+ "properties": {
+ "session": {
+ "$ref": "#/components/schemas/DocumentUpload"
+ },
+ "uploadToken": {
+ "type": "string",
+ "minLength": 1
+ },
+ "transfer": {
+ "$ref": "#/components/schemas/UploadTransfer"
+ }
+ }
+ }
+ }
+ },
+ "CreatePartUrlsBody": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["partNumbers"],
+ "properties": {
+ "partNumbers": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 100,
+ "items": {
+ "type": "integer",
+ "minimum": 1
+ }
+ }
+ }
+ },
+ "UploadPartUrl": {
+ "type": "object",
+ "required": ["partNumber", "url", "headers", "expiresAt"],
+ "properties": {
+ "partNumber": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "url": {
+ "type": "string",
+ "format": "uri"
+ },
+ "headers": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "expiresAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "PartUrlsEnvelope": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["parts"],
+ "properties": {
+ "parts": {
+ "type": "array",
+ "maxItems": 100,
+ "items": {
+ "$ref": "#/components/schemas/UploadPartUrl"
+ }
+ }
+ }
+ }
+ }
+ },
+ "DocumentSummary": {
+ "type": "object",
+ "description": "Summary representation of a document, returned in list operations and as the upload acknowledgement.",
+ "required": [
+ "id",
+ "knowledgeBaseId",
+ "filename",
+ "fileSize",
+ "mimeType",
+ "processingStatus",
+ "chunkCount",
+ "tokenCount",
+ "characterCount",
+ "enabled",
+ "createdAt"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique document identifier.",
+ "example": "b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12"
+ },
+ "knowledgeBaseId": {
+ "type": "string",
+ "description": "The knowledge base this document belongs to.",
+ "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
+ },
+ "filename": {
+ "type": "string",
+ "description": "Original filename of the uploaded document.",
+ "example": "getting-started.pdf"
+ },
+ "fileSize": {
+ "type": "integer",
+ "description": "Size of the file in bytes.",
+ "example": 248913
+ },
+ "mimeType": {
+ "type": "string",
+ "description": "MIME type of the file.",
+ "example": "application/pdf"
+ },
+ "processingStatus": {
+ "type": "string",
+ "description": "Current processing state of the document.",
+ "enum": ["pending", "processing", "completed", "failed"],
+ "example": "completed"
+ },
+ "chunkCount": {
+ "type": "integer",
+ "description": "Number of chunks the document was split into. 0 until processing completes.",
+ "example": 24
+ },
+ "tokenCount": {
+ "type": "integer",
+ "description": "Total number of tokens extracted from the document.",
+ "example": 8123
+ },
+ "characterCount": {
+ "type": "integer",
+ "description": "Total number of characters extracted from the document.",
+ "example": 41205
+ },
+ "enabled": {
+ "type": "boolean",
+ "description": "Whether the document is enabled for search.",
+ "example": true
+ },
+ "createdAt": {
+ "type": ["string", "null"],
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when the document was uploaded.",
+ "example": "2025-06-18T16:45:00Z"
+ }
+ }
+ },
+ "DocumentSummaryEnvelope": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["document"],
+ "properties": {
+ "document": {
+ "$ref": "#/components/schemas/DocumentSummary"
+ }
+ }
+ }
+ }
+ },
+ "Document": {
+ "type": "object",
+ "description": "Full document detail: the summary fields plus processing state and connector provenance.",
+ "required": [
+ "id",
+ "knowledgeBaseId",
+ "filename",
+ "fileSize",
+ "mimeType",
+ "processingStatus",
+ "chunkCount",
+ "tokenCount",
+ "characterCount",
+ "enabled",
+ "createdAt",
+ "processingError",
+ "processingStartedAt",
+ "processingCompletedAt",
+ "connectorId",
+ "connectorType",
+ "sourceUrl"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique document identifier.",
+ "example": "b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12"
+ },
+ "knowledgeBaseId": {
+ "type": "string",
+ "description": "The knowledge base this document belongs to.",
+ "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
+ },
+ "filename": {
+ "type": "string",
+ "description": "Original filename of the uploaded document.",
+ "example": "getting-started.pdf"
+ },
+ "fileSize": {
+ "type": "integer",
+ "description": "Size of the file in bytes.",
+ "example": 248913
+ },
+ "mimeType": {
+ "type": "string",
+ "description": "MIME type of the file.",
+ "example": "application/pdf"
+ },
+ "processingStatus": {
+ "type": "string",
+ "description": "Current processing state of the document.",
+ "enum": ["pending", "processing", "completed", "failed"],
+ "example": "completed"
+ },
+ "chunkCount": {
+ "type": "integer",
+ "description": "Number of chunks the document was split into. 0 until processing completes.",
+ "example": 24
+ },
+ "tokenCount": {
+ "type": "integer",
+ "description": "Total number of tokens extracted from the document.",
+ "example": 8123
+ },
+ "characterCount": {
+ "type": "integer",
+ "description": "Total number of characters extracted from the document.",
+ "example": 41205
+ },
+ "enabled": {
+ "type": "boolean",
+ "description": "Whether the document is enabled for search.",
+ "example": true
+ },
+ "createdAt": {
+ "type": ["string", "null"],
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when the document was uploaded.",
+ "example": "2025-06-18T16:45:00Z"
+ },
+ "processingError": {
+ "type": ["string", "null"],
+ "description": "Error message if processing failed, otherwise null.",
+ "example": null
+ },
+ "processingStartedAt": {
+ "type": ["string", "null"],
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when processing started, or null.",
+ "example": "2025-06-18T16:45:05Z"
+ },
+ "processingCompletedAt": {
+ "type": ["string", "null"],
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when processing completed, or null.",
+ "example": "2025-06-18T16:45:42Z"
+ },
+ "connectorId": {
+ "type": ["string", "null"],
+ "description": "Identifier of the connector that synced this document, or null for direct uploads.",
+ "example": null
+ },
+ "connectorType": {
+ "type": ["string", "null"],
+ "description": "Type of the connector that synced this document, or null for direct uploads.",
+ "example": null
+ },
+ "sourceUrl": {
+ "type": ["string", "null"],
+ "description": "Original source URL of the document for connector-synced documents, or null.",
+ "example": null
+ }
+ }
+ },
+ "DocumentEnvelope": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["document"],
+ "properties": {
+ "document": {
+ "$ref": "#/components/schemas/Document"
+ }
+ }
+ }
+ }
+ },
+ "SearchTagFilter": {
+ "type": "object",
+ "description": "A structured tag filter applied to search. Tag filters are only supported when searching a single knowledge base.",
+ "required": ["tagName", "value"],
+ "properties": {
+ "tagName": {
+ "type": "string",
+ "description": "The display name of the tag to filter on.",
+ "example": "category"
+ },
+ "fieldType": {
+ "type": "string",
+ "description": "The tag's field type.",
+ "enum": ["text", "number", "date", "boolean"]
+ },
+ "operator": {
+ "type": "string",
+ "description": "Comparison operator. Valid operators depend on the field type.",
+ "default": "eq",
+ "example": "eq"
+ },
+ "value": {
+ "description": "The value to compare against.",
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "number"
+ },
+ {
+ "type": "boolean"
+ }
+ ],
+ "example": "billing"
+ },
+ "valueTo": {
+ "description": "Upper bound for the `between` operator (number or date).",
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "number"
+ }
+ ]
+ }
+ }
+ },
+ "SearchBody": {
+ "type": "object",
+ "description": "Request body for knowledge search. At least one of `query` or `tagFilters` must be provided.",
+ "required": ["workspaceId", "knowledgeBaseIds"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace that owns the knowledge bases.",
+ "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64"
+ },
+ "knowledgeBaseIds": {
+ "description": "A single knowledge base ID or an array of up to 20 IDs to search.",
+ "oneOf": [
+ {
+ "type": "string",
+ "minLength": 1,
+ "description": "A single knowledge base ID."
+ },
+ {
+ "type": "array",
+ "description": "An array of knowledge base IDs.",
+ "items": {
+ "type": "string",
+ "minLength": 1
+ },
+ "minItems": 1,
+ "maxItems": 20
+ }
+ ],
+ "example": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"]
+ },
+ "query": {
+ "type": "string",
+ "description": "The natural-language query for semantic vector search. Required if `tagFilters` is omitted.",
+ "example": "How do I reset my password?"
+ },
+ "topK": {
+ "type": "integer",
+ "description": "Maximum number of results to return.",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 10
+ },
+ "tagFilters": {
+ "type": "array",
+ "description": "Structured tag filters. Only supported when searching a single knowledge base. Required if `query` is omitted.",
+ "items": {
+ "$ref": "#/components/schemas/SearchTagFilter"
+ }
+ },
+ "searchMode": {
+ "type": "string",
+ "enum": ["vector", "hybrid"],
+ "default": "vector",
+ "description": "Retrieval strategy. `vector` is semantic-only; `hybrid` additionally runs a full-text leg and fuses the two by reciprocal rank, which recovers exact tokens (error codes, ticket keys, identifiers) that embeddings rank poorly."
+ }
+ }
+ },
+ "SearchResult": {
+ "type": "object",
+ "description": "A single search hit (a matching document chunk).",
+ "required": [
+ "documentId",
+ "documentName",
+ "sourceUrl",
+ "content",
+ "chunkIndex",
+ "metadata",
+ "similarity"
+ ],
+ "properties": {
+ "documentId": {
+ "type": "string",
+ "description": "Identifier of the document the chunk belongs to.",
+ "example": "b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12"
+ },
+ "documentName": {
+ "type": ["string", "null"],
+ "description": "Filename of the source document, or null if unavailable.",
+ "example": "getting-started.pdf"
+ },
+ "sourceUrl": {
+ "type": ["string", "null"],
+ "description": "Original source URL of the document, or null for direct uploads.",
+ "example": null
+ },
+ "content": {
+ "type": "string",
+ "description": "The matching chunk's text content.",
+ "example": "To reset your password, open Settings and choose \"Security\"."
+ },
+ "chunkIndex": {
+ "type": "integer",
+ "description": "Zero-based index of the chunk within its document.",
+ "example": 3
+ },
+ "metadata": {
+ "type": "object",
+ "description": "The document's tag values keyed by tag display name. Values are user-defined and may be strings, numbers, booleans, or dates.",
+ "additionalProperties": true,
+ "example": {
+ "category": "billing",
+ "priority": 2
+ }
+ },
+ "similarity": {
+ "type": "number",
+ "description": "Similarity score in the range 0–1 for vector search (higher is more similar). 1 for tag-only matches.",
+ "example": 0.8423
+ }
+ }
+ },
+ "SearchEnvelope": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["results", "query", "knowledgeBaseIds", "topK", "totalResults"],
+ "properties": {
+ "results": {
+ "type": "array",
+ "description": "The matching chunks, ordered by relevance.",
+ "items": {
+ "$ref": "#/components/schemas/SearchResult"
+ }
+ },
+ "query": {
+ "type": "string",
+ "description": "The query that was executed (empty string for tag-only search).",
+ "example": "How do I reset my password?"
+ },
+ "knowledgeBaseIds": {
+ "type": "array",
+ "description": "The knowledge base IDs that were searched.",
+ "items": {
+ "type": "string"
+ },
+ "example": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"]
+ },
+ "topK": {
+ "type": "integer",
+ "description": "The maximum number of results requested.",
+ "example": 10
+ },
+ "totalResults": {
+ "type": "integer",
+ "description": "The number of results returned.",
+ "example": 4
+ }
+ }
+ }
+ }
+ },
+ "DeleteEnvelope": {
+ "type": "object",
+ "description": "Delete acknowledgement.",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["id", "deleted"],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The id of the resource that was deleted.",
+ "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
+ },
+ "deleted": {
+ "type": "boolean",
+ "description": "Always true.",
+ "enum": [true],
+ "example": true
+ }
+ }
+ }
+ }
+ },
+ "Error": {
+ "type": "object",
+ "description": "The canonical v2 error envelope.",
+ "required": ["error"],
+ "properties": {
+ "error": {
+ "type": "object",
+ "required": ["code", "message"],
+ "properties": {
+ "code": {
+ "type": "string",
+ "description": "Stable, machine-readable error code.",
+ "enum": [
+ "BAD_REQUEST",
+ "UNAUTHORIZED",
+ "FORBIDDEN",
+ "NOT_FOUND",
+ "CONFLICT",
+ "PAYLOAD_TOO_LARGE",
+ "UNSUPPORTED_MEDIA_TYPE",
+ "USAGE_LIMIT_EXCEEDED",
+ "LOCKED",
+ "RATE_LIMITED",
+ "INTERNAL_ERROR"
+ ]
+ },
+ "message": {
+ "type": "string",
+ "description": "Human-readable description of the error."
+ },
+ "details": {
+ "description": "Optional structured context for the error, such as field-level validation issues."
+ }
+ }
+ }
+ }
+ },
+ "KnowledgeFolder": {
+ "type": "object",
+ "required": ["name", "path", "parentPath", "createdAt", "updatedAt"],
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Folder name."
+ },
+ "path": {
+ "type": "string",
+ "description": "Canonical folder path. This is the public folder identifier."
+ },
+ "parentPath": {
+ "type": "string",
+ "description": "Canonical parent path; `/` is the root."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ }
+ },
+ "responses": {
+ "BadRequest": {
+ "description": "The request was malformed or failed validation. Inspect `error.details` for field-level issues.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "BAD_REQUEST",
+ "message": "Invalid request",
+ "details": [
+ {
+ "path": "workspaceId",
+ "message": "workspaceId query parameter is required"
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "Unauthorized": {
+ "description": "The API key is missing or invalid. Ensure the X-API-Key header is set with a valid key.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "UNAUTHORIZED",
+ "message": "Invalid API key"
+ }
+ }
+ }
+ }
+ },
+ "Forbidden": {
+ "description": "The authenticated caller does not have access to the requested workspace or resource.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "FORBIDDEN",
+ "message": "Access denied"
+ }
+ }
+ }
+ }
+ },
+ "NotFound": {
+ "description": "The requested resource does not exist or is not accessible from this workspace.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "NOT_FOUND",
+ "message": "Knowledge base not found"
+ }
+ }
+ }
+ }
+ },
+ "Conflict": {
+ "description": "The request conflicts with the current state of the resource (for example, a resource with the same name already exists).",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "CONFLICT",
+ "message": "Resource already exists"
+ }
+ }
+ }
+ }
+ },
+ "UsageLimitExceeded": {
+ "description": "The workspace has exceeded its usage or billing limits. Upgrade the plan to continue.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "USAGE_LIMIT_EXCEEDED",
+ "message": "Usage limit exceeded. Please upgrade your plan to continue."
+ }
+ }
+ }
+ }
+ },
+ "PayloadTooLarge": {
+ "description": "The request payload exceeds the allowed size, or the workspace storage limit has been reached.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "PAYLOAD_TOO_LARGE",
+ "message": "Storage limit exceeded"
+ }
+ }
+ }
+ }
+ },
+ "UnsupportedMediaType": {
+ "description": "The uploaded file's MIME type or extension is not supported.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "UNSUPPORTED_MEDIA_TYPE",
+ "message": "Unsupported file type"
+ }
+ }
+ }
+ }
+ },
+ "RateLimited": {
+ "description": "The rate limit has been exceeded. Retry after the period indicated by the Retry-After header.",
+ "headers": {
+ "Retry-After": {
+ "$ref": "#/components/headers/RetryAfter"
+ },
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "RATE_LIMITED",
+ "message": "API rate limit exceeded",
+ "details": {
+ "retryAfter": "2025-06-20T14:16:00Z"
+ }
+ }
+ }
+ }
+ }
+ },
+ "InternalError": {
+ "description": "An unexpected error occurred on the server.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "INTERNAL_ERROR",
+ "message": "Internal server error"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json
new file mode 100644
index 00000000000..0fd9b86657c
--- /dev/null
+++ b/apps/docs/openapi-v2-logs.json
@@ -0,0 +1,1065 @@
+{
+ "openapi": "3.1.0",
+ "info": {
+ "title": "Sim API v2 — Logs",
+ "description": "Version 2 of the Sim API for workflow execution logs. v2 standardizes every response on a single envelope: a single resource returns `{ data }`, a list returns `{ data, nextCursor }`, and an error returns `{ error: { code, message, details? } }`. Lists use opaque cursor pagination (`limit` + `cursor` in, `nextCursor` out). Rate-limit state is carried in the `X-RateLimit-*` response headers rather than the body. Authenticate every request with the `X-API-Key` header.",
+ "version": "2.0.0",
+ "contact": {
+ "name": "Sim Support",
+ "email": "help@sim.ai",
+ "url": "https://www.sim.ai"
+ },
+ "license": {
+ "name": "Apache 2.0",
+ "url": "https://www.apache.org/licenses/LICENSE-2.0.html"
+ }
+ },
+ "servers": [
+ {
+ "url": "https://www.sim.ai",
+ "description": "Production"
+ }
+ ],
+ "security": [
+ {
+ "apiKey": []
+ }
+ ],
+ "tags": [
+ {
+ "name": "Logs",
+ "description": "Query workflow execution logs, retrieve a single log entry, and fetch the full execution state snapshot for a run."
+ }
+ ],
+ "paths": {
+ "/api/v2/logs": {
+ "get": {
+ "operationId": "listLogs",
+ "summary": "List Logs",
+ "description": "List workflow execution logs for a workspace with filtering and opaque cursor pagination. Returns `{ data, nextCursor }`. By default (`details=basic`) each entry contains summary fields only; pass `details=full` to include the per-execution `workflow` summary, and additionally `includeFinalOutput=true` / `includeTraceSpans=true` to materialize `finalOutput` / `traceSpans` on each entry.",
+ "tags": ["Logs"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/logs?workspaceId=YOUR_WORKSPACE_ID&limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkspaceId"
+ },
+ {
+ "name": "workflowIds",
+ "in": "query",
+ "description": "Comma-separated list of workflow IDs to filter by. Only logs from these workflows are returned.",
+ "schema": {
+ "type": "string"
+ },
+ "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36,8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91"
+ },
+ {
+ "name": "folderPaths",
+ "in": "query",
+ "description": "Comma-separated list of folder paths. Returns logs for all workflows within these folders.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "triggers",
+ "in": "query",
+ "description": "Comma-separated trigger types to filter by (e.g. api, webhook, schedule, manual, chat).",
+ "schema": {
+ "type": "string"
+ },
+ "example": "api,schedule"
+ },
+ {
+ "name": "level",
+ "in": "query",
+ "description": "Filter logs by severity level. info for successful executions, error for failed ones.",
+ "schema": {
+ "type": "string",
+ "enum": ["info", "error"]
+ }
+ },
+ {
+ "name": "startDate",
+ "in": "query",
+ "description": "Only return logs started at or after this ISO 8601 timestamp.",
+ "schema": {
+ "type": "string",
+ "format": "date-time"
+ }
+ },
+ {
+ "name": "endDate",
+ "in": "query",
+ "description": "Only return logs started at or before this ISO 8601 timestamp.",
+ "schema": {
+ "type": "string",
+ "format": "date-time"
+ }
+ },
+ {
+ "name": "executionId",
+ "in": "query",
+ "description": "Filter by an exact execution ID. Useful for looking up a specific run.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "minDurationMs",
+ "in": "query",
+ "description": "Only return logs where total execution duration was at least this many milliseconds.",
+ "schema": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ {
+ "name": "maxDurationMs",
+ "in": "query",
+ "description": "Only return logs where total execution duration was at most this many milliseconds.",
+ "schema": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ {
+ "name": "minCost",
+ "in": "query",
+ "description": "Only return logs where execution cost was at least this amount in USD.",
+ "schema": {
+ "type": "number",
+ "minimum": 0
+ }
+ },
+ {
+ "name": "maxCost",
+ "in": "query",
+ "description": "Only return logs where execution cost was at most this amount in USD.",
+ "schema": {
+ "type": "number",
+ "minimum": 0
+ }
+ },
+ {
+ "name": "model",
+ "in": "query",
+ "description": "Filter by the AI model used during execution (e.g., gpt-4o, claude-sonnet-4-20250514).",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "details",
+ "in": "query",
+ "description": "Response detail level. basic returns summary fields only. full additionally includes the per-entry workflow summary and enables the includeFinalOutput / includeTraceSpans materialization flags.",
+ "schema": {
+ "type": "string",
+ "enum": ["basic", "full"],
+ "default": "basic"
+ }
+ },
+ {
+ "name": "includeTraceSpans",
+ "in": "query",
+ "description": "When true, includes block-level execution trace spans on each entry. Only applies when details=full.",
+ "schema": {
+ "type": "boolean",
+ "default": false
+ }
+ },
+ {
+ "name": "includeFinalOutput",
+ "in": "query",
+ "description": "When true, includes the workflow's final output on each entry. Only applies when details=full.",
+ "schema": {
+ "type": "boolean",
+ "default": false
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "description": "Maximum number of log entries to return per page. Values are clamped to the range 1–1000.",
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 1000,
+ "default": 100
+ }
+ },
+ {
+ "name": "cursor",
+ "in": "query",
+ "description": "Opaque pagination cursor returned from a previous request's nextCursor field. Omit to fetch the first page.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "order",
+ "in": "query",
+ "description": "Sort order by execution start time. desc returns newest first.",
+ "schema": {
+ "type": "string",
+ "enum": ["desc", "asc"],
+ "default": "desc"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "A page of execution logs matching the filter criteria.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data", "nextCursor"],
+ "properties": {
+ "data": {
+ "type": "array",
+ "description": "Log entries for the current page.",
+ "items": {
+ "$ref": "#/components/schemas/LogListItem"
+ }
+ },
+ "nextCursor": {
+ "type": ["string", "null"],
+ "description": "Opaque cursor for fetching the next page. null when there are no more results."
+ }
+ }
+ },
+ "example": {
+ "data": [
+ {
+ "id": "log_7x8y9z0a1b",
+ "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36",
+ "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13",
+ "deploymentVersionId": "dep_2c4e6a8b0d1f",
+ "level": "info",
+ "trigger": "api",
+ "startedAt": "2026-01-15T10:30:00.000Z",
+ "endedAt": "2026-01-15T10:30:01.250Z",
+ "totalDurationMs": 1250,
+ "cost": {
+ "total": 0.0032
+ },
+ "files": null
+ }
+ ],
+ "nextCursor": "eyJzdGFydGVkQXQiOiIyMDI2LTAxLTE1VDEwOjMwOjAwLjAwMFoiLCJpZCI6ImxvZ183eDh5OXowYTFiIn0="
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/logs/{id}": {
+ "get": {
+ "operationId": "getLog",
+ "summary": "Get Log",
+ "description": "Retrieve a single log entry by its ID, including the workflow metadata captured at execution time, the materialized execution data, and the cost summary. Returns `{ data }`.",
+ "tags": ["Logs"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/logs/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "description": "The unique identifier of the log entry.",
+ "schema": {
+ "type": "string",
+ "example": "log_7x8y9z0a1b"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The requested log entry with full execution data and cost summary.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/LogDetail"
+ }
+ }
+ },
+ "example": {
+ "data": {
+ "id": "log_7x8y9z0a1b",
+ "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36",
+ "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13",
+ "level": "info",
+ "trigger": "api",
+ "startedAt": "2026-01-15T10:30:00.000Z",
+ "endedAt": "2026-01-15T10:30:01.250Z",
+ "totalDurationMs": 1250,
+ "files": null,
+ "workflow": {
+ "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36",
+ "name": "Customer Support Agent",
+ "description": "Routes incoming support tickets and drafts responses",
+ "folderPath": "/",
+ "userId": "usr_1a2b3c4d5e",
+ "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
+ "createdAt": "2025-01-10T09:00:00.000Z",
+ "updatedAt": "2025-06-18T16:45:00.000Z",
+ "deleted": false
+ },
+ "executionData": {
+ "traceSpans": [],
+ "finalOutput": {
+ "result": "Hello, world!"
+ }
+ },
+ "cost": {
+ "total": 0.0032
+ },
+ "createdAt": "2026-01-15T10:30:00.000Z"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/logs/executions/{executionId}": {
+ "get": {
+ "operationId": "getExecution",
+ "summary": "Get Execution",
+ "description": "Retrieve the full execution state snapshot for a run: the workflow state captured at execution time plus execution metadata (trigger, timing, and cost). Returns `{ data }`.",
+ "tags": ["Logs"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/logs/executions/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "name": "executionId",
+ "in": "path",
+ "required": true,
+ "description": "The unique execution identifier.",
+ "schema": {
+ "type": "string",
+ "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The full execution state snapshot with workflow state and metadata.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/Execution"
+ }
+ }
+ },
+ "example": {
+ "data": {
+ "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13",
+ "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36",
+ "workflowState": {
+ "blocks": {},
+ "edges": [],
+ "loops": {},
+ "parallels": {}
+ },
+ "executionMetadata": {
+ "trigger": "api",
+ "startedAt": "2026-01-15T10:30:00.000Z",
+ "endedAt": "2026-01-15T10:30:01.250Z",
+ "totalDurationMs": 1250,
+ "cost": {
+ "total": 0.0032
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "securitySchemes": {
+ "apiKey": {
+ "type": "apiKey",
+ "in": "header",
+ "name": "X-API-Key",
+ "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys."
+ }
+ },
+ "parameters": {
+ "WorkspaceId": {
+ "name": "workspaceId",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ },
+ "description": "The unique identifier of the workspace whose logs to query."
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "description": "Maximum number of requests allowed in the current rate-limit window.",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ "X-RateLimit-Remaining": {
+ "description": "Number of requests remaining in the current rate-limit window.",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ "X-RateLimit-Reset": {
+ "description": "ISO 8601 timestamp when the current rate-limit window resets.",
+ "schema": {
+ "type": "string",
+ "format": "date-time"
+ }
+ },
+ "Retry-After": {
+ "description": "Number of seconds to wait before retrying the request.",
+ "schema": {
+ "type": "integer"
+ }
+ }
+ },
+ "schemas": {
+ "Cost": {
+ "type": ["object", "null"],
+ "description": "Aggregate execution cost in USD. null when no cost was recorded for the run.",
+ "required": ["total"],
+ "properties": {
+ "total": {
+ "type": "number",
+ "description": "Total cost of the execution in USD.",
+ "example": 0.0032
+ }
+ }
+ },
+ "LogWorkflowSummary": {
+ "type": "object",
+ "description": "Workflow summary captured at execution time. Present on a list entry only when details=full.",
+ "required": ["id", "name", "description", "deleted"],
+ "properties": {
+ "id": {
+ "type": ["string", "null"],
+ "description": "The workflow's identifier. null if the log is not associated with a workflow.",
+ "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"
+ },
+ "name": {
+ "type": "string",
+ "description": "Workflow name. Falls back to \"Deleted Workflow\" when the workflow no longer exists.",
+ "example": "Customer Support Agent"
+ },
+ "description": {
+ "type": ["string", "null"],
+ "description": "Workflow description, or null if none was set.",
+ "example": "Routes incoming support tickets and drafts responses"
+ },
+ "deleted": {
+ "type": "boolean",
+ "description": "Whether the workflow has since been deleted.",
+ "example": false
+ }
+ }
+ },
+ "LogWorkflowDetail": {
+ "type": "object",
+ "description": "Full workflow metadata captured at execution time.",
+ "required": [
+ "id",
+ "name",
+ "description",
+ "folderPath",
+ "userId",
+ "workspaceId",
+ "createdAt",
+ "updatedAt",
+ "deleted"
+ ],
+ "properties": {
+ "id": {
+ "type": ["string", "null"],
+ "description": "The workflow's identifier. null if the log is not associated with a workflow.",
+ "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"
+ },
+ "name": {
+ "type": "string",
+ "description": "Workflow name. Falls back to \"Deleted Workflow\" when the workflow no longer exists.",
+ "example": "Customer Support Agent"
+ },
+ "description": {
+ "type": ["string", "null"],
+ "description": "Workflow description, or null if none was set.",
+ "example": "Routes incoming support tickets and drafts responses"
+ },
+ "folderPath": {
+ "type": "string",
+ "description": "Canonical containing-folder path. `/` is the workspace root.",
+ "example": "/Engineering"
+ },
+ "userId": {
+ "type": ["string", "null"],
+ "description": "The user that owns the workflow. null if the workflow is gone.",
+ "example": "usr_1a2b3c4d5e"
+ },
+ "workspaceId": {
+ "type": ["string", "null"],
+ "description": "The workspace the workflow belongs to. null if the workflow is gone.",
+ "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64"
+ },
+ "createdAt": {
+ "type": ["string", "null"],
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when the workflow was created. null if the workflow is gone.",
+ "example": "2025-01-10T09:00:00.000Z"
+ },
+ "updatedAt": {
+ "type": ["string", "null"],
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when the workflow was last modified. null if the workflow is gone.",
+ "example": "2025-06-18T16:45:00.000Z"
+ },
+ "deleted": {
+ "type": "boolean",
+ "description": "Whether the workflow has since been deleted.",
+ "example": false
+ }
+ }
+ },
+ "LogListItem": {
+ "type": "object",
+ "description": "Summary of a single workflow execution log entry returned by the list endpoint.",
+ "required": [
+ "id",
+ "workflowId",
+ "executionId",
+ "deploymentVersionId",
+ "level",
+ "trigger",
+ "startedAt",
+ "endedAt",
+ "totalDurationMs",
+ "cost",
+ "files"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique log entry identifier.",
+ "example": "log_7x8y9z0a1b"
+ },
+ "workflowId": {
+ "type": ["string", "null"],
+ "description": "The workflow that was executed. null if the log is not associated with a workflow.",
+ "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"
+ },
+ "executionId": {
+ "type": "string",
+ "description": "Unique execution identifier for this run.",
+ "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13"
+ },
+ "deploymentVersionId": {
+ "type": ["string", "null"],
+ "description": "The deployment version that produced this run. null for runs not tied to a deployment.",
+ "example": "dep_2c4e6a8b0d1f"
+ },
+ "level": {
+ "type": "string",
+ "description": "Log severity. info for successful executions, error for failures.",
+ "example": "info"
+ },
+ "trigger": {
+ "type": "string",
+ "description": "How the execution was triggered (e.g., api, webhook, schedule, manual, chat).",
+ "example": "api"
+ },
+ "startedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when execution started.",
+ "example": "2026-01-15T10:30:00.000Z"
+ },
+ "endedAt": {
+ "type": ["string", "null"],
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when execution completed. null if the run has not finished.",
+ "example": "2026-01-15T10:30:01.250Z"
+ },
+ "totalDurationMs": {
+ "type": ["integer", "null"],
+ "description": "Total execution duration in milliseconds. null if the run has not finished.",
+ "example": 1250
+ },
+ "cost": {
+ "$ref": "#/components/schemas/Cost"
+ },
+ "files": {
+ "type": ["array", "null"],
+ "description": "Attachment metadata for files produced during the run. null when the run produced no files.",
+ "items": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ },
+ "workflow": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/LogWorkflowSummary"
+ }
+ ],
+ "description": "Workflow summary. Present only when details=full."
+ },
+ "finalOutput": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "The workflow's final output. The shape depends on the workflow. Present only when details=full and includeFinalOutput=true."
+ },
+ "traceSpans": {
+ "type": "array",
+ "description": "Block-level execution trace spans with timing, inputs, and outputs. Present only when details=full and includeTraceSpans=true.",
+ "items": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ },
+ "LogDetail": {
+ "type": "object",
+ "description": "Detailed log entry with full workflow metadata, materialized execution data, and cost summary.",
+ "required": [
+ "id",
+ "workflowId",
+ "executionId",
+ "level",
+ "trigger",
+ "startedAt",
+ "endedAt",
+ "totalDurationMs",
+ "files",
+ "workflow",
+ "executionData",
+ "cost",
+ "createdAt"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique log entry identifier.",
+ "example": "log_7x8y9z0a1b"
+ },
+ "workflowId": {
+ "type": ["string", "null"],
+ "description": "The workflow that was executed. null if the log is not associated with a workflow.",
+ "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"
+ },
+ "executionId": {
+ "type": "string",
+ "description": "Unique execution identifier for this run.",
+ "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13"
+ },
+ "level": {
+ "type": "string",
+ "description": "Log severity. info for successful executions, error for failures.",
+ "example": "info"
+ },
+ "trigger": {
+ "type": "string",
+ "description": "How the execution was triggered (e.g., api, webhook, schedule, manual, chat).",
+ "example": "api"
+ },
+ "startedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when execution started.",
+ "example": "2026-01-15T10:30:00.000Z"
+ },
+ "endedAt": {
+ "type": ["string", "null"],
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when execution completed. null if the run has not finished.",
+ "example": "2026-01-15T10:30:01.250Z"
+ },
+ "totalDurationMs": {
+ "type": ["integer", "null"],
+ "description": "Total execution duration in milliseconds. null if the run has not finished.",
+ "example": 1250
+ },
+ "files": {
+ "type": ["array", "null"],
+ "description": "Attachment metadata for files produced during the run. null when the run produced no files.",
+ "items": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ },
+ "workflow": {
+ "$ref": "#/components/schemas/LogWorkflowDetail"
+ },
+ "executionData": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "Materialized execution trace for this run (block states, trace spans, and final output). Large blobs stored externally are resolved inline.",
+ "properties": {
+ "traceSpans": {
+ "type": "array",
+ "description": "Block-level execution traces with timing, inputs, and outputs.",
+ "items": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ },
+ "finalOutput": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "The workflow's final output after all blocks completed."
+ }
+ }
+ },
+ "cost": {
+ "$ref": "#/components/schemas/Cost"
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when the log entry was recorded.",
+ "example": "2026-01-15T10:30:00.000Z"
+ }
+ }
+ },
+ "Execution": {
+ "type": "object",
+ "description": "Full execution state snapshot: the workflow state at execution time plus execution metadata.",
+ "required": ["executionId", "workflowId", "workflowState", "executionMetadata"],
+ "properties": {
+ "executionId": {
+ "type": "string",
+ "description": "The unique identifier for this execution.",
+ "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13"
+ },
+ "workflowId": {
+ "type": ["string", "null"],
+ "description": "The workflow that was executed. null if the log is not associated with a workflow.",
+ "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"
+ },
+ "workflowState": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "Snapshot of the workflow configuration at the time of execution.",
+ "properties": {
+ "blocks": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "Map of block IDs to their configuration and state during execution."
+ },
+ "edges": {
+ "type": "array",
+ "description": "Connections between blocks defining the execution flow.",
+ "items": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ },
+ "loops": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "Loop configurations defining iterative execution patterns."
+ },
+ "parallels": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "Parallel execution group configurations."
+ }
+ }
+ },
+ "executionMetadata": {
+ "type": "object",
+ "description": "Metadata about the execution including trigger, timing, and cost.",
+ "required": ["trigger", "startedAt", "endedAt", "totalDurationMs", "cost"],
+ "properties": {
+ "trigger": {
+ "type": "string",
+ "description": "How the execution was triggered (e.g., api, webhook, schedule, manual, chat).",
+ "example": "api"
+ },
+ "startedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when execution started.",
+ "example": "2026-01-15T10:30:00.000Z"
+ },
+ "endedAt": {
+ "type": ["string", "null"],
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when execution completed. null if the run has not finished.",
+ "example": "2026-01-15T10:30:01.250Z"
+ },
+ "totalDurationMs": {
+ "type": ["integer", "null"],
+ "description": "Total execution duration in milliseconds. null if the run has not finished.",
+ "example": 1250
+ },
+ "cost": {
+ "$ref": "#/components/schemas/Cost"
+ }
+ }
+ }
+ }
+ },
+ "Error": {
+ "type": "object",
+ "description": "Canonical v2 error envelope.",
+ "required": ["error"],
+ "properties": {
+ "error": {
+ "type": "object",
+ "required": ["code", "message"],
+ "properties": {
+ "code": {
+ "type": "string",
+ "description": "Machine-readable error code (e.g., BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, RATE_LIMITED, INTERNAL_ERROR).",
+ "example": "NOT_FOUND"
+ },
+ "message": {
+ "type": "string",
+ "description": "Human-readable error message.",
+ "example": "Log not found"
+ },
+ "details": {
+ "description": "Optional structured details about the error (e.g., field-level validation issues or rate-limit reset info). Present only on some errors."
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "BadRequest": {
+ "description": "Invalid request parameters. Inspect error.details for field-level validation issues.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "BAD_REQUEST",
+ "message": "Invalid request",
+ "details": [
+ {
+ "path": "workspaceId",
+ "message": "Workspace ID is required"
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "Unauthorized": {
+ "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "UNAUTHORIZED",
+ "message": "API key required"
+ }
+ }
+ }
+ }
+ },
+ "Forbidden": {
+ "description": "The API key is authenticated but not authorized for the requested workspace.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "FORBIDDEN",
+ "message": "API key is not authorized for this workspace"
+ }
+ }
+ }
+ }
+ },
+ "NotFound": {
+ "description": "The requested resource was not found. An authorization failure on a single resource is also reported as 404 so resource existence is not leaked.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "NOT_FOUND",
+ "message": "Log not found"
+ }
+ }
+ }
+ }
+ },
+ "RateLimited": {
+ "description": "Rate limit exceeded. Wait for the duration in the Retry-After header before retrying.",
+ "headers": {
+ "Retry-After": {
+ "$ref": "#/components/headers/Retry-After"
+ },
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "RATE_LIMITED",
+ "message": "API rate limit exceeded",
+ "details": {
+ "retryAfter": "2026-01-15T10:31:00.000Z"
+ }
+ }
+ }
+ }
+ }
+ },
+ "InternalError": {
+ "description": "An unexpected error occurred while processing the request.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "INTERNAL_ERROR",
+ "message": "Internal server error"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json
new file mode 100644
index 00000000000..be371e32bcc
--- /dev/null
+++ b/apps/docs/openapi-v2-resources.json
@@ -0,0 +1,3164 @@
+{
+ "openapi": "3.1.0",
+ "info": {
+ "title": "Sim API v2 — Workspace Resources",
+ "description": "The v2 Workspace Resources API covers the resources a workspace is provisioned with: MCP servers, skills, custom tools, folders, and credentials.\n\n## Conventions\n\nAll endpoints live under the `/api/v2` base path and share a single set of conventions:\n\n- **Authentication** — Send your Sim API key in the `X-API-Key` header on every request. Keys are scoped to a workspace (or are personal keys that target a workspace); `workspaceId` is always required so the request can be tenant-scoped and rate-limited.\n- **Single-resource and mutation responses** return `{ \"data\": ... }`.\n- **List responses** use an opaque-cursor envelope: `{ \"data\": [ ... ], \"nextCursor\": string | null }`. Pass the returned `nextCursor` back as the `cursor` query parameter to fetch the next page. When `nextCursor` is `null` there are no more results. Cursors are opaque — do not parse or construct them.\n- **Errors** use a single envelope: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`. The HTTP status code and the stable `code` field move together (for example `404` ⇄ `NOT_FOUND`).\n- **Rate limiting** — Every response carries the current limiter state in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. A throttled request returns `429` with a `Retry-After` header.\n- **Secrets are write-only** — Fields that carry secret material (MCP request headers, credential values) are accepted on write and never returned on read. Reads expose only whether a secret is configured, and for headers their names.",
+ "version": "2.0.0",
+ "contact": {
+ "name": "Sim Support",
+ "email": "help@sim.ai",
+ "url": "https://www.sim.ai"
+ },
+ "license": {
+ "name": "Apache 2.0",
+ "url": "https://www.apache.org/licenses/LICENSE-2.0.html"
+ }
+ },
+ "servers": [
+ {
+ "url": "https://www.sim.ai",
+ "description": "Production"
+ }
+ ],
+ "tags": [
+ {
+ "name": "MCP Servers",
+ "description": "Register and manage the Model Context Protocol servers a workspace connects to (v2 API)."
+ },
+ {
+ "name": "Skills",
+ "description": "Create and manage the reusable instruction documents agents can be given (v2 API)."
+ },
+ {
+ "name": "Custom Tools",
+ "description": "Create and manage the workspace's own code-backed tools that agents can call (v2 API)."
+ },
+ {
+ "name": "Credentials",
+ "description": "Provision the secrets and connected accounts a workspace's agents authenticate with (v2 API)."
+ }
+ ],
+ "security": [
+ {
+ "apiKey": []
+ }
+ ],
+ "paths": {
+ "/api/v2/mcp-servers": {
+ "get": {
+ "operationId": "listMcpServers",
+ "summary": "List MCP Servers",
+ "description": "List the MCP servers registered in a workspace. The per-workspace set is small and bounded, so the full set is returned as a single page and `nextCursor` is always `null`; treat the response as a standard cursor list so pagination can be added later without a contract change.\n\nConfigured request header **values** are never returned — use `hasHeaders` and `headerNames` to see which headers are set.",
+ "tags": ["MCP Servers"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl \\\n \"https://www.sim.ai/api/v2/mcp-servers?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ },
+ {
+ "name": "search",
+ "in": "query",
+ "required": false,
+ "description": "Case-insensitive substring match against the MCP server `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 200
+ }
+ },
+ {
+ "name": "sortBy",
+ "in": "query",
+ "required": false,
+ "description": "Field to sort by.",
+ "schema": {
+ "type": "string",
+ "enum": ["name", "createdAt", "updatedAt"],
+ "default": "createdAt"
+ }
+ },
+ {
+ "name": "sortOrder",
+ "in": "query",
+ "required": false,
+ "description": "Sort direction.",
+ "schema": {
+ "type": "string",
+ "enum": ["asc", "desc"],
+ "default": "desc"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "MCP servers registered in the workspace.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data", "nextCursor"],
+ "properties": {
+ "data": {
+ "type": "array",
+ "description": "The MCP servers registered in the workspace.",
+ "items": {
+ "$ref": "#/components/schemas/McpServer"
+ }
+ },
+ "nextCursor": {
+ "$ref": "#/components/schemas/NextCursor"
+ }
+ }
+ },
+ "example": {
+ "data": [
+ {
+ "id": "mcp-3f7a9c21",
+ "name": "Docs server",
+ "description": "Internal documentation tools",
+ "transport": "streamable-http",
+ "authType": "headers",
+ "url": "https://mcp.example.com/sse",
+ "timeout": 30000,
+ "retries": 3,
+ "enabled": true,
+ "connectionStatus": "connected",
+ "lastError": null,
+ "toolCount": 7,
+ "lastToolsRefresh": "2025-06-20T14:02:11.000Z",
+ "lastConnected": "2025-06-20T14:02:11.000Z",
+ "createdAt": "2025-06-01T09:14:00.000Z",
+ "updatedAt": "2025-06-20T14:02:11.000Z",
+ "hasHeaders": true,
+ "headerNames": ["Authorization"],
+ "hasOauthClientSecret": false
+ }
+ ],
+ "nextCursor": null
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "post": {
+ "operationId": "createMcpServer",
+ "summary": "Create MCP Server",
+ "description": "Register a new MCP server in a workspace. Requires `write` permission on the workspace.\n\nA server's identity is derived from its URL, so registering a URL that is already registered returns `409 CONFLICT` rather than overwriting the existing server — use `PATCH /api/v2/mcp-servers/{id}` to change one.\n\nThe `url` must be an absolute `http`/`https` URL and may not contain `{{ENV_VAR}}` references: templated hostnames defer domain-allowlist and SSRF checks to call time, which is not safe to accept over an API key.\n\n`headers` and `oauthClientSecret` are write-only and are never returned.",
+ "tags": ["MCP Servers"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/mcp-servers\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"Docs server\",\n \"url\": \"https://mcp.example.com/sse\",\n \"headers\": { \"Authorization\": \"Bearer YOUR_TOKEN\" }\n }'"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "The MCP server to register.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateMcpServerBody"
+ },
+ "examples": {
+ "headerAuth": {
+ "summary": "Header-authenticated server",
+ "value": {
+ "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
+ "name": "Docs server",
+ "description": "Internal documentation tools",
+ "url": "https://mcp.example.com/sse",
+ "authType": "headers",
+ "headers": {
+ "Authorization": "Bearer YOUR_TOKEN"
+ },
+ "timeout": 30000,
+ "retries": 3
+ }
+ },
+ "oauth": {
+ "summary": "OAuth server with pre-registered client credentials",
+ "value": {
+ "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
+ "name": "Partner server",
+ "url": "https://mcp.partner.example.com/mcp",
+ "authType": "oauth",
+ "oauthClientId": "sim-client",
+ "oauthClientSecret": "YOUR_CLIENT_SECRET"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "The MCP server was registered.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/McpServerData"
+ },
+ "example": {
+ "data": {
+ "mcpServer": {
+ "id": "mcp-3f7a9c21",
+ "name": "Docs server",
+ "description": "Internal documentation tools",
+ "transport": "streamable-http",
+ "authType": "headers",
+ "url": "https://mcp.example.com/sse",
+ "timeout": 30000,
+ "retries": 3,
+ "enabled": true,
+ "connectionStatus": "connected",
+ "lastError": null,
+ "toolCount": 0,
+ "createdAt": "2025-06-20T14:02:11.000Z",
+ "updatedAt": "2025-06-20T14:02:11.000Z",
+ "hasHeaders": true,
+ "headerNames": ["Authorization"],
+ "hasOauthClientSecret": false
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/mcp-servers/{id}": {
+ "get": {
+ "operationId": "getMcpServer",
+ "summary": "Get MCP Server",
+ "description": "Fetch a single MCP server by id. Configured request header values and the OAuth client secret are never returned.",
+ "tags": ["MCP Servers"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl \\\n \"https://www.sim.ai/api/v2/mcp-servers/mcp-3f7a9c21?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/McpServerId"
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The MCP server.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/McpServerData"
+ },
+ "example": {
+ "data": {
+ "mcpServer": {
+ "id": "mcp-3f7a9c21",
+ "name": "Docs server",
+ "transport": "streamable-http",
+ "authType": "headers",
+ "url": "https://mcp.example.com/sse",
+ "enabled": true,
+ "connectionStatus": "connected",
+ "lastError": null,
+ "toolCount": 7,
+ "createdAt": "2025-06-01T09:14:00.000Z",
+ "updatedAt": "2025-06-20T14:02:11.000Z",
+ "hasHeaders": true,
+ "headerNames": ["Authorization"],
+ "hasOauthClientSecret": false
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "patch": {
+ "operationId": "updateMcpServer",
+ "summary": "Update MCP Server",
+ "description": "Update an MCP server's configuration. Only the fields you send are changed. Requires `write` permission on the workspace.\n\n`url` is immutable: a server's id is derived from its URL, so re-pointing it would leave the id hashing an address the server no longer uses and allow two servers on one URL. Sending a different `url` returns `400` — delete the server and create one at the new address. Sending the URL it already has is accepted, so a full-object PATCH still works.\n\nChanging the auth type or the OAuth client credentials invalidates any existing OAuth grant for the server and resets its connection state.",
+ "tags": ["MCP Servers"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/mcp-servers/mcp-3f7a9c21\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"enabled\": false\n }'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/McpServerId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "The fields to change. `workspaceId` is required so the request is tenant-scoped.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateMcpServerBody"
+ },
+ "examples": {
+ "disable": {
+ "summary": "Disable a server",
+ "value": {
+ "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
+ "enabled": false
+ }
+ },
+ "rotateHeaders": {
+ "summary": "Rotate the auth header",
+ "value": {
+ "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
+ "headers": {
+ "Authorization": "Bearer NEW_TOKEN"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The updated MCP server.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/McpServerData"
+ },
+ "example": {
+ "data": {
+ "mcpServer": {
+ "id": "mcp-3f7a9c21",
+ "name": "Docs server",
+ "transport": "streamable-http",
+ "authType": "headers",
+ "url": "https://mcp.example.com/sse",
+ "enabled": false,
+ "connectionStatus": "connected",
+ "lastError": null,
+ "toolCount": 7,
+ "createdAt": "2025-06-01T09:14:00.000Z",
+ "updatedAt": "2025-06-21T08:30:00.000Z",
+ "hasHeaders": true,
+ "headerNames": ["Authorization"],
+ "hasOauthClientSecret": false
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "delete": {
+ "operationId": "deleteMcpServer",
+ "summary": "Delete MCP Server",
+ "description": "Remove an MCP server from the workspace and revoke any OAuth tokens issued for it. Requires `write` permission on the workspace. Workflows that referenced the server's tools keep their blocks but can no longer call it.",
+ "tags": ["MCP Servers"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/mcp-servers/mcp-3f7a9c21?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/McpServerId"
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The MCP server was deleted.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DeleteAcknowledgement"
+ },
+ "example": {
+ "data": {
+ "id": "mcp-3f7a9c21",
+ "deleted": true
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/skills": {
+ "get": {
+ "operationId": "listSkills",
+ "summary": "List Skills",
+ "description": "List the skills available in a workspace. Built-in template skills that ship with Sim are included and are marked `readOnly: true`.\n\nSkill bodies can be up to 50 000 characters, so the list returns summaries only — fetch `GET /api/v2/skills/{id}` for a skill's `content`. The per-workspace set is small and bounded, so the full set is returned as a single page and `nextCursor` is always `null`.",
+ "tags": ["Skills"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl \\\n \"https://www.sim.ai/api/v2/skills?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ },
+ {
+ "name": "search",
+ "in": "query",
+ "required": false,
+ "description": "Case-insensitive substring match against the skill `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 200
+ }
+ },
+ {
+ "name": "sortBy",
+ "in": "query",
+ "required": false,
+ "description": "Field to sort by. Built-in skills have no stored timestamps and sort as if created at the Unix epoch.",
+ "schema": {
+ "type": "string",
+ "enum": ["name", "createdAt", "updatedAt"],
+ "default": "createdAt"
+ }
+ },
+ {
+ "name": "sortOrder",
+ "in": "query",
+ "required": false,
+ "description": "Sort direction.",
+ "schema": {
+ "type": "string",
+ "enum": ["asc", "desc"],
+ "default": "desc"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Skills available in the workspace.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data", "nextCursor"],
+ "properties": {
+ "data": {
+ "type": "array",
+ "description": "The skills available in the workspace, without their bodies.",
+ "items": {
+ "$ref": "#/components/schemas/SkillSummary"
+ }
+ },
+ "nextCursor": {
+ "$ref": "#/components/schemas/NextCursor"
+ }
+ }
+ },
+ "example": {
+ "data": [
+ {
+ "id": "deploy-workflow",
+ "name": "deploy-workflow",
+ "description": "How to deploy a finished workflow",
+ "readOnly": true,
+ "createdAt": "1970-01-01T00:00:00.000Z",
+ "updatedAt": "1970-01-01T00:00:00.000Z"
+ },
+ {
+ "id": "V1StGXR8Z5jdHi6BmyT",
+ "name": "refund-policy",
+ "description": "How support should handle refund requests",
+ "readOnly": false,
+ "createdAt": "2025-06-01T09:14:00.000Z",
+ "updatedAt": "2025-06-20T14:02:11.000Z"
+ }
+ ],
+ "nextCursor": null
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "post": {
+ "operationId": "createSkill",
+ "summary": "Create Skill",
+ "description": "Create a skill in a workspace. Requires `write` permission on the workspace, and the creator becomes an editor of the new skill.\n\n`name` must be kebab-case and unique in the workspace; names reserved by built-in skills are rejected. Unlike the internal endpoint this creates exactly one skill and answers with it, not with the whole workspace list.",
+ "tags": ["Skills"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/skills\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"refund-policy\",\n \"description\": \"How support should handle refund requests\",\n \"content\": \"# Refund policy\\n\\nAlways check the order date first.\"\n }'"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "The skill to create.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateSkillBody"
+ },
+ "examples": {
+ "refundPolicy": {
+ "summary": "A support playbook",
+ "value": {
+ "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
+ "name": "refund-policy",
+ "description": "How support should handle refund requests",
+ "content": "# Refund policy\n\nAlways check the order date first."
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "The skill was created.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SkillData"
+ },
+ "example": {
+ "data": {
+ "skill": {
+ "id": "V1StGXR8Z5jdHi6BmyT",
+ "name": "refund-policy",
+ "description": "How support should handle refund requests",
+ "content": "# Refund policy\n\nAlways check the order date first.",
+ "readOnly": false,
+ "createdAt": "2025-06-20T14:02:11.000Z",
+ "updatedAt": "2025-06-20T14:02:11.000Z"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/skills/{id}": {
+ "get": {
+ "operationId": "getSkill",
+ "summary": "Get Skill",
+ "description": "Fetch a single skill by id, including its full `content`. Built-in template skills resolve here too and are marked `readOnly: true`.",
+ "tags": ["Skills"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl \\\n \"https://www.sim.ai/api/v2/skills/V1StGXR8Z5jdHi6BmyT?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/SkillId"
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The skill.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SkillData"
+ },
+ "example": {
+ "data": {
+ "skill": {
+ "id": "V1StGXR8Z5jdHi6BmyT",
+ "name": "refund-policy",
+ "description": "How support should handle refund requests",
+ "content": "# Refund policy\n\nAlways check the order date first.",
+ "readOnly": false,
+ "createdAt": "2025-06-01T09:14:00.000Z",
+ "updatedAt": "2025-06-20T14:02:11.000Z"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "patch": {
+ "operationId": "updateSkill",
+ "summary": "Update Skill",
+ "description": "Update a skill. Only the fields you send are changed, so a partial edit never clobbers a concurrent change to a field you did not send.\n\nRequires skill editor access — an explicit editor grant on the skill, or workspace admin. Built-in skills are read-only and are rejected.",
+ "tags": ["Skills"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/skills/V1StGXR8Z5jdHi6BmyT\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"description\": \"Updated refund guidance\"\n }'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/SkillId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "The fields to change. At least one of `name`, `description`, or `content` is required.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateSkillBody"
+ },
+ "examples": {
+ "editDescription": {
+ "summary": "Change the description only",
+ "value": {
+ "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
+ "description": "Updated refund guidance"
+ }
+ },
+ "replaceContent": {
+ "summary": "Replace the skill body",
+ "value": {
+ "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
+ "content": "# Refund policy\n\nCheck the order date, then the payment method."
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The updated skill.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SkillData"
+ },
+ "example": {
+ "data": {
+ "skill": {
+ "id": "V1StGXR8Z5jdHi6BmyT",
+ "name": "refund-policy",
+ "description": "Updated refund guidance",
+ "content": "# Refund policy\n\nAlways check the order date first.",
+ "readOnly": false,
+ "createdAt": "2025-06-01T09:14:00.000Z",
+ "updatedAt": "2025-06-21T08:30:00.000Z"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "delete": {
+ "operationId": "deleteSkill",
+ "summary": "Delete Skill",
+ "description": "Delete a skill from the workspace. Requires skill editor access — an explicit editor grant on the skill, or workspace admin. Built-in skills are read-only and are rejected.",
+ "tags": ["Skills"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/skills/V1StGXR8Z5jdHi6BmyT?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/SkillId"
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The skill was deleted.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DeleteAcknowledgement"
+ },
+ "example": {
+ "data": {
+ "id": "V1StGXR8Z5jdHi6BmyT",
+ "deleted": true
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/custom-tools": {
+ "get": {
+ "operationId": "listCustomTools",
+ "summary": "List Custom Tools",
+ "description": "List the custom tools defined in a workspace. Custom tools are code-backed functions agents can call, declared with an OpenAI-style function schema.\n\nOnly workspace tools are returned — legacy personal tools, which predate workspace scoping and belong to a single user, are not part of the public API. The per-workspace set is small and bounded, so the full set is returned as a single page and `nextCursor` is always `null`.",
+ "tags": ["Custom Tools"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl \\\n \"https://www.sim.ai/api/v2/custom-tools?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ },
+ {
+ "name": "search",
+ "in": "query",
+ "required": false,
+ "description": "Case-insensitive substring match against the custom tool `title`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 200
+ }
+ },
+ {
+ "name": "sortBy",
+ "in": "query",
+ "required": false,
+ "description": "Field to sort by.",
+ "schema": {
+ "type": "string",
+ "enum": ["title", "createdAt", "updatedAt"],
+ "default": "createdAt"
+ }
+ },
+ {
+ "name": "sortOrder",
+ "in": "query",
+ "required": false,
+ "description": "Sort direction.",
+ "schema": {
+ "type": "string",
+ "enum": ["asc", "desc"],
+ "default": "desc"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Custom tools defined in the workspace.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data", "nextCursor"],
+ "properties": {
+ "data": {
+ "type": "array",
+ "description": "The custom tools defined in the workspace.",
+ "items": {
+ "$ref": "#/components/schemas/CustomTool"
+ }
+ },
+ "nextCursor": {
+ "$ref": "#/components/schemas/NextCursor"
+ }
+ }
+ },
+ "example": {
+ "data": [
+ {
+ "id": "V1StGXR8Z5jdHi6BmyT",
+ "title": "lookup_order",
+ "schema": {
+ "type": "function",
+ "function": {
+ "name": "lookup_order",
+ "description": "Look up an order by id",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "orderId": {
+ "type": "string"
+ }
+ },
+ "required": ["orderId"]
+ }
+ }
+ },
+ "code": "const res = await fetch(`https://api.example.com/orders/${orderId}`)\nreturn await res.json()",
+ "createdAt": "2025-06-01T09:14:00.000Z",
+ "updatedAt": "2025-06-20T14:02:11.000Z"
+ }
+ ],
+ "nextCursor": null
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "post": {
+ "operationId": "createCustomTool",
+ "summary": "Create Custom Tool",
+ "description": "Create a custom tool in a workspace. Requires `write` permission on the workspace.\n\n`title` must be unique within the workspace — tools resolve by title at call time, so a duplicate returns `409 CONFLICT`. `code` is the tool body, executed in Sim's sandboxed function runtime with the schema's parameters bound as variables.",
+ "tags": ["Custom Tools"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/custom-tools\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"title\": \"lookup_order\",\n \"schema\": {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"lookup_order\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": { \"orderId\": { \"type\": \"string\" } },\n \"required\": [\"orderId\"]\n }\n }\n },\n \"code\": \"return { ok: true }\"\n }'"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "The custom tool to create.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateCustomToolBody"
+ },
+ "examples": {
+ "lookupOrder": {
+ "summary": "A tool that calls an internal API",
+ "value": {
+ "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
+ "title": "lookup_order",
+ "schema": {
+ "type": "function",
+ "function": {
+ "name": "lookup_order",
+ "description": "Look up an order by id",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "orderId": {
+ "type": "string"
+ }
+ },
+ "required": ["orderId"]
+ }
+ }
+ },
+ "code": "const res = await fetch(`https://api.example.com/orders/${orderId}`)\nreturn await res.json()"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "The custom tool was created.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CustomToolData"
+ },
+ "example": {
+ "data": {
+ "customTool": {
+ "id": "V1StGXR8Z5jdHi6BmyT",
+ "title": "lookup_order",
+ "schema": {
+ "type": "function",
+ "function": {
+ "name": "lookup_order",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "orderId": {
+ "type": "string"
+ }
+ },
+ "required": ["orderId"]
+ }
+ }
+ },
+ "code": "return { ok: true }",
+ "createdAt": "2025-06-20T14:02:11.000Z",
+ "updatedAt": "2025-06-20T14:02:11.000Z"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/custom-tools/{id}": {
+ "get": {
+ "operationId": "getCustomTool",
+ "summary": "Get Custom Tool",
+ "description": "Fetch a single custom tool by id, scoped to the workspace.",
+ "tags": ["Custom Tools"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl \\\n \"https://www.sim.ai/api/v2/custom-tools/V1StGXR8Z5jdHi6BmyT?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/CustomToolId"
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The custom tool.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CustomToolData"
+ },
+ "example": {
+ "data": {
+ "customTool": {
+ "id": "V1StGXR8Z5jdHi6BmyT",
+ "title": "lookup_order",
+ "schema": {
+ "type": "function",
+ "function": {
+ "name": "lookup_order",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "orderId": {
+ "type": "string"
+ }
+ },
+ "required": ["orderId"]
+ }
+ }
+ },
+ "code": "return { ok: true }",
+ "createdAt": "2025-06-01T09:14:00.000Z",
+ "updatedAt": "2025-06-20T14:02:11.000Z"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "patch": {
+ "operationId": "updateCustomTool",
+ "summary": "Update Custom Tool",
+ "description": "Update a custom tool. Only the fields you send are changed; omitted fields keep their stored values. Requires `write` permission on the workspace.\n\nRenaming onto a title another tool already uses returns `409 CONFLICT`.",
+ "tags": ["Custom Tools"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/custom-tools/V1StGXR8Z5jdHi6BmyT\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"code\": \"return { ok: false }\"\n }'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/CustomToolId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "The fields to change. At least one of `title`, `schema`, or `code` is required.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateCustomToolBody"
+ },
+ "examples": {
+ "editCode": {
+ "summary": "Replace the implementation only",
+ "value": {
+ "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
+ "code": "return { ok: false }"
+ }
+ },
+ "rename": {
+ "summary": "Rename the tool",
+ "value": {
+ "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
+ "title": "find_order"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The updated custom tool.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CustomToolData"
+ },
+ "example": {
+ "data": {
+ "customTool": {
+ "id": "V1StGXR8Z5jdHi6BmyT",
+ "title": "lookup_order",
+ "schema": {
+ "type": "function",
+ "function": {
+ "name": "lookup_order",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "orderId": {
+ "type": "string"
+ }
+ },
+ "required": ["orderId"]
+ }
+ }
+ },
+ "code": "return { ok: false }",
+ "createdAt": "2025-06-01T09:14:00.000Z",
+ "updatedAt": "2025-06-21T08:30:00.000Z"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "delete": {
+ "operationId": "deleteCustomTool",
+ "summary": "Delete Custom Tool",
+ "description": "Delete a custom tool from the workspace. Requires `write` permission on the workspace. Agent blocks that referenced the tool keep their configuration but can no longer call it.",
+ "tags": ["Custom Tools"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/custom-tools/V1StGXR8Z5jdHi6BmyT?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/CustomToolId"
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The custom tool was deleted.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DeleteAcknowledgement"
+ },
+ "example": {
+ "data": {
+ "id": "V1StGXR8Z5jdHi6BmyT",
+ "deleted": true
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/credentials": {
+ "get": {
+ "operationId": "listCredentials",
+ "summary": "List Credentials",
+ "description": "List the credentials you can see in a workspace. Visibility is per credential: an explicit membership grant, plus — for workspace admins — every shared credential, plus your own personal environment credentials.\n\n**Secret material is never returned.** A read tells you a secret is configured (`hasServiceAccountKey`) and nothing more. The workspace's credential set is small and bounded, so the full visible set is returned as a single page and `nextCursor` is always `null`.",
+ "tags": ["Credentials"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl \\\n \"https://www.sim.ai/api/v2/credentials?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ },
+ {
+ "name": "type",
+ "in": "query",
+ "required": false,
+ "description": "Only return credentials of this kind.",
+ "schema": {
+ "type": "string",
+ "enum": ["oauth", "env_workspace", "env_personal", "service_account"]
+ }
+ },
+ {
+ "name": "providerId",
+ "in": "query",
+ "required": false,
+ "description": "Only return credentials for this integration.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "example": "slack"
+ }
+ },
+ {
+ "name": "search",
+ "in": "query",
+ "required": false,
+ "description": "Case-insensitive substring match against the credential `displayName`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 200
+ }
+ },
+ {
+ "name": "sortBy",
+ "in": "query",
+ "required": false,
+ "description": "Field to sort by.",
+ "schema": {
+ "type": "string",
+ "enum": ["displayName", "createdAt", "updatedAt"],
+ "default": "createdAt"
+ }
+ },
+ {
+ "name": "sortOrder",
+ "in": "query",
+ "required": false,
+ "description": "Sort direction.",
+ "schema": {
+ "type": "string",
+ "enum": ["asc", "desc"],
+ "default": "desc"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Credentials visible to the caller in the workspace.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data", "nextCursor"],
+ "properties": {
+ "data": {
+ "type": "array",
+ "description": "The credentials visible to the caller.",
+ "items": {
+ "$ref": "#/components/schemas/Credential"
+ }
+ },
+ "nextCursor": {
+ "$ref": "#/components/schemas/NextCursor"
+ }
+ }
+ },
+ "example": {
+ "data": [
+ {
+ "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
+ "type": "service_account",
+ "displayName": "Zoom account acct_123",
+ "description": null,
+ "providerId": "zoom-service-account",
+ "accountId": null,
+ "envKey": null,
+ "hasServiceAccountKey": true,
+ "role": "admin",
+ "createdAt": "2025-06-01T09:14:00.000Z",
+ "updatedAt": "2025-06-20T14:02:11.000Z"
+ }
+ ],
+ "nextCursor": null
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "post": {
+ "operationId": "createCredential",
+ "summary": "Create Credential",
+ "description": "Create a workspace credential. Requires `write` permission on the workspace; the creator becomes an admin of the credential.\n\n`oauth` credentials **cannot** be created here — they are minted by the interactive OAuth connect flow and bound to an account you authorized in a browser. The creatable types are:\n\n- `env_workspace` — a secret stored under `envKey`, available to everyone in the workspace.\n- `env_personal` — the same, scoped to you.\n- `service_account` — a provider secret (`serviceAccountJson`, `apiToken` + `domain`, `clientId` + `clientSecret` + `orgId`, …). The secret is verified against the provider before it is stored.\n\nEvery secret field is write-only and is never returned. Creation is idempotent on the credential's source (the account, the env key, or the provider + name), so re-issuing the same create returns the existing credential rather than a duplicate.",
+ "tags": ["Credentials"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/credentials\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"type\": \"env_workspace\",\n \"envKey\": \"STRIPE_API_KEY\"\n }'"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "The credential to create.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateCredentialBody"
+ },
+ "examples": {
+ "workspaceEnvVar": {
+ "summary": "A workspace-wide environment secret",
+ "value": {
+ "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
+ "type": "env_workspace",
+ "envKey": "STRIPE_API_KEY"
+ }
+ },
+ "clientCredentialServiceAccount": {
+ "summary": "A client-credentials service account",
+ "value": {
+ "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
+ "type": "service_account",
+ "providerId": "zoom-service-account",
+ "clientId": "YOUR_CLIENT_ID",
+ "clientSecret": "YOUR_CLIENT_SECRET",
+ "orgId": "YOUR_ACCOUNT_ID"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "The credential exists with this source. Returned whether it was inserted now or already present.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CredentialData"
+ },
+ "example": {
+ "data": {
+ "credential": {
+ "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
+ "type": "env_workspace",
+ "displayName": "STRIPE_API_KEY",
+ "description": null,
+ "providerId": null,
+ "accountId": null,
+ "envKey": "STRIPE_API_KEY",
+ "hasServiceAccountKey": false,
+ "role": "admin",
+ "createdAt": "2025-06-20T14:02:11.000Z",
+ "updatedAt": "2025-06-20T14:02:11.000Z"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ },
+ "503": {
+ "$ref": "#/components/responses/ServiceUnavailable"
+ }
+ }
+ }
+ },
+ "/api/v2/credentials/{id}": {
+ "get": {
+ "operationId": "getCredential",
+ "summary": "Get Credential",
+ "description": "Fetch a single credential. Secret material is never returned — `hasServiceAccountKey` tells you whether one is stored.\n\nA credential you have no grant on answers `404`, not `403`, so its existence is never disclosed to someone who cannot use it.",
+ "tags": ["Credentials"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl \\\n \"https://www.sim.ai/api/v2/credentials/7c9e6679-7425-40de-944b-e07fc1f90ae7?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/CredentialId"
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The credential.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CredentialData"
+ },
+ "example": {
+ "data": {
+ "credential": {
+ "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
+ "type": "service_account",
+ "displayName": "Zoom account acct_123",
+ "description": null,
+ "providerId": "zoom-service-account",
+ "accountId": null,
+ "envKey": null,
+ "hasServiceAccountKey": true,
+ "role": "admin",
+ "createdAt": "2025-06-01T09:14:00.000Z",
+ "updatedAt": "2025-06-20T14:02:11.000Z"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "patch": {
+ "operationId": "updateCredential",
+ "summary": "Update Credential",
+ "description": "Rename a credential, change its description, or rotate its stored secret. Requires credential admin — access to the workspace plus admin rights on the credential itself. Workspace `write` is not required: credentials are gated per credential, not per workspace.\n\nSending a secret field rotates that secret in place: it is re-verified against the provider and re-encrypted, and the display name is preserved. Secrets are never returned in the response. If the provider cannot be reached to verify the new secret, the request returns `503`.\n\nA credential you cannot see answers `404` rather than `403`, so its existence is never disclosed.",
+ "tags": ["Credentials"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/credentials/7c9e6679-7425-40de-944b-e07fc1f90ae7\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"displayName\": \"Zoom (production)\"\n }'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/CredentialId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "The fields to change. At least one field besides `workspaceId` is required.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateCredentialBody"
+ },
+ "examples": {
+ "rename": {
+ "summary": "Rename a credential",
+ "value": {
+ "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
+ "displayName": "Zoom (production)"
+ }
+ },
+ "rotateSecret": {
+ "summary": "Rotate an API token",
+ "value": {
+ "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
+ "apiToken": "YOUR_NEW_TOKEN"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The updated credential.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CredentialData"
+ },
+ "example": {
+ "data": {
+ "credential": {
+ "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
+ "type": "service_account",
+ "displayName": "Zoom (production)",
+ "description": null,
+ "providerId": "zoom-service-account",
+ "accountId": null,
+ "envKey": null,
+ "hasServiceAccountKey": true,
+ "role": "admin",
+ "createdAt": "2025-06-01T09:14:00.000Z",
+ "updatedAt": "2025-06-21T08:30:00.000Z"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ },
+ "503": {
+ "$ref": "#/components/responses/ServiceUnavailable"
+ }
+ }
+ },
+ "delete": {
+ "operationId": "deleteCredential",
+ "summary": "Delete Credential",
+ "description": "Delete a credential. Requires credential admin — access to the workspace plus admin rights on the credential itself; workspace `write` is not required. Blocks and workflows configured against it stop authenticating, and any environment variable it backed is removed.\n\nA credential you cannot see answers `404` rather than `403`.",
+ "tags": ["Credentials"],
+ "x-codeSamples": [
+ {
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/credentials/7c9e6679-7425-40de-944b-e07fc1f90ae7?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/CredentialId"
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The credential was deleted.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DeleteAcknowledgement"
+ },
+ "example": {
+ "data": {
+ "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
+ "deleted": true
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "securitySchemes": {
+ "apiKey": {
+ "type": "apiKey",
+ "in": "header",
+ "name": "X-API-Key",
+ "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys."
+ }
+ },
+ "headers": {
+ "RateLimitLimit": {
+ "description": "The maximum number of requests permitted in the current rate-limit window.",
+ "schema": {
+ "type": "integer",
+ "example": 60
+ }
+ },
+ "RateLimitRemaining": {
+ "description": "The number of requests remaining in the current rate-limit window.",
+ "schema": {
+ "type": "integer",
+ "example": 59
+ }
+ },
+ "RateLimitReset": {
+ "description": "ISO 8601 timestamp at which the current rate-limit window resets.",
+ "schema": {
+ "type": "string",
+ "format": "date-time",
+ "example": "2025-06-20T14:16:00Z"
+ }
+ },
+ "RetryAfter": {
+ "description": "Number of seconds to wait before retrying the request.",
+ "schema": {
+ "type": "integer",
+ "example": 30
+ }
+ }
+ },
+ "parameters": {
+ "WorkspaceIdQuery": {
+ "name": "workspaceId",
+ "in": "query",
+ "required": true,
+ "description": "The unique identifier of the workspace that scopes the request.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64"
+ }
+ },
+ "McpServerId": {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "description": "The unique identifier of the MCP server.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "example": "mcp-3f7a9c21"
+ }
+ },
+ "SkillId": {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "description": "The unique identifier of the skill. Built-in skills use their name as their id.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "example": "V1StGXR8Z5jdHi6BmyT"
+ }
+ },
+ "CustomToolId": {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "description": "The unique identifier of the custom tool.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "example": "V1StGXR8Z5jdHi6BmyT"
+ }
+ },
+ "FolderId": {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "description": "The unique identifier of the folder.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
+ }
+ },
+ "CredentialId": {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "description": "The unique identifier of the credential.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
+ }
+ }
+ },
+ "responses": {
+ "BadRequest": {
+ "description": "The request was malformed or failed validation. Inspect `error.details` for field-level issues.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "BAD_REQUEST",
+ "message": "Workspace ID is required",
+ "details": [
+ {
+ "path": "workspaceId",
+ "message": "Workspace ID is required"
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "Unauthorized": {
+ "description": "The API key is missing or invalid. Ensure the X-API-Key header is set with a valid key.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "UNAUTHORIZED",
+ "message": "Invalid API key"
+ }
+ }
+ }
+ }
+ },
+ "Forbidden": {
+ "description": "The authenticated caller does not have the required permission on the workspace, or the URL was rejected by the server's MCP domain policy.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "FORBIDDEN",
+ "message": "Access denied"
+ }
+ }
+ }
+ }
+ },
+ "NotFound": {
+ "description": "The requested resource does not exist or is not accessible from this workspace.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "NOT_FOUND",
+ "message": "MCP server not found"
+ }
+ }
+ }
+ }
+ },
+ "Conflict": {
+ "description": "The request conflicts with the current state of the workspace — for example a resource with the same identity already exists.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "CONFLICT",
+ "message": "An MCP server with this URL already exists in this workspace."
+ }
+ }
+ }
+ }
+ },
+ "Locked": {
+ "description": "A mutation lock on the resource (or something inside it) blocks the change. Unlock it and retry.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "LOCKED",
+ "message": "This folder is locked and cannot be modified"
+ }
+ }
+ }
+ }
+ },
+ "RateLimited": {
+ "description": "The rate limit has been exceeded. Retry after the period indicated by the Retry-After header.",
+ "headers": {
+ "Retry-After": {
+ "$ref": "#/components/headers/RetryAfter"
+ },
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "RATE_LIMITED",
+ "message": "API rate limit exceeded",
+ "details": {
+ "retryAfter": "2025-06-20T14:16:00Z"
+ }
+ }
+ }
+ }
+ }
+ },
+ "InternalError": {
+ "description": "An unexpected error occurred on the server.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "INTERNAL_ERROR",
+ "message": "Internal server error"
+ }
+ }
+ }
+ }
+ },
+ "ServiceUnavailable": {
+ "description": "An upstream provider could not be reached to verify the request. Retry shortly.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "SERVICE_UNAVAILABLE",
+ "message": "The credential provider is unavailable. Try again."
+ }
+ }
+ }
+ }
+ }
+ },
+ "schemas": {
+ "Error": {
+ "type": "object",
+ "description": "The canonical v2 error envelope.",
+ "required": ["error"],
+ "properties": {
+ "error": {
+ "type": "object",
+ "required": ["code", "message"],
+ "properties": {
+ "code": {
+ "type": "string",
+ "description": "Stable, machine-readable error code.",
+ "enum": [
+ "BAD_REQUEST",
+ "UNAUTHORIZED",
+ "FORBIDDEN",
+ "NOT_FOUND",
+ "CONFLICT",
+ "LOCKED",
+ "RATE_LIMITED",
+ "INTERNAL_ERROR",
+ "SERVICE_UNAVAILABLE"
+ ]
+ },
+ "message": {
+ "type": "string",
+ "description": "Human-readable description of the error."
+ },
+ "details": {
+ "description": "Optional structured context for the error, such as field-level validation issues."
+ }
+ }
+ }
+ }
+ },
+ "NextCursor": {
+ "type": ["string", "null"],
+ "description": "Opaque cursor for the next page, or null when there are no more results. Pass it back as the `cursor` query parameter. Do not parse or construct cursors.",
+ "example": null
+ },
+ "DeleteAcknowledgement": {
+ "type": "object",
+ "description": "Acknowledgement that a resource was deleted.",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["id", "deleted"],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The identifier of the resource that was deleted."
+ },
+ "deleted": {
+ "type": "boolean",
+ "const": true
+ }
+ }
+ }
+ }
+ },
+ "McpServer": {
+ "type": "object",
+ "description": "An MCP server registered in a workspace. Request header values and the OAuth client secret are write-only and never appear here.",
+ "required": [
+ "id",
+ "name",
+ "transport",
+ "enabled",
+ "createdAt",
+ "updatedAt",
+ "hasHeaders",
+ "headerNames",
+ "hasOauthClientSecret"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The server's unique identifier, derived from the workspace and the server URL."
+ },
+ "name": {
+ "type": "string",
+ "description": "Display name of the server."
+ },
+ "description": {
+ "type": "string",
+ "description": "Optional description."
+ },
+ "transport": {
+ "type": "string",
+ "enum": ["streamable-http"],
+ "description": "Transport used to talk to the server."
+ },
+ "authType": {
+ "type": "string",
+ "enum": ["none", "headers", "oauth"],
+ "description": "How Sim authenticates to the server."
+ },
+ "url": {
+ "type": "string",
+ "description": "The server's endpoint URL."
+ },
+ "timeout": {
+ "type": "number",
+ "description": "Per-request timeout in milliseconds."
+ },
+ "retries": {
+ "type": "number",
+ "description": "Number of retries per request."
+ },
+ "enabled": {
+ "type": "boolean",
+ "description": "Whether the server's tools are available to workflows."
+ },
+ "connectionStatus": {
+ "type": "string",
+ "enum": ["connected", "disconnected", "error"],
+ "description": "Result of the most recent connection attempt."
+ },
+ "lastError": {
+ "type": ["string", "null"],
+ "description": "Message from the most recent failed connection, if any."
+ },
+ "toolCount": {
+ "type": "number",
+ "description": "Number of tools discovered on the server."
+ },
+ "lastToolsRefresh": {
+ "type": "string",
+ "format": "date-time",
+ "description": "When the server's tool list was last refreshed."
+ },
+ "lastConnected": {
+ "type": "string",
+ "format": "date-time",
+ "description": "When Sim last connected successfully."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "oauthClientId": {
+ "type": "string",
+ "description": "Pre-registered OAuth client id, when the server does not support dynamic client registration."
+ },
+ "hasHeaders": {
+ "type": "boolean",
+ "description": "Whether any request headers are configured. Values are never returned."
+ },
+ "headerNames": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Names of the configured request headers. Values are never returned."
+ },
+ "hasOauthClientSecret": {
+ "type": "boolean",
+ "description": "Whether an OAuth client secret is stored for this server."
+ }
+ }
+ },
+ "McpServerData": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["mcpServer"],
+ "properties": {
+ "mcpServer": {
+ "$ref": "#/components/schemas/McpServer"
+ }
+ }
+ }
+ }
+ },
+ "CreateMcpServerBody": {
+ "type": "object",
+ "description": "A new MCP server registration.",
+ "additionalProperties": false,
+ "required": ["workspaceId", "name", "url"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace to register the server in."
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255,
+ "description": "Display name of the server."
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 2000,
+ "description": "Optional description."
+ },
+ "transport": {
+ "type": "string",
+ "enum": ["streamable-http"],
+ "description": "Transport used to talk to the server. Defaults to `streamable-http`."
+ },
+ "url": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 2048,
+ "description": "Absolute http or https endpoint URL. May not contain `{{ENV_VAR}}` references."
+ },
+ "authType": {
+ "type": "string",
+ "enum": ["none", "headers", "oauth"],
+ "description": "How Sim should authenticate. Detected from the server when omitted."
+ },
+ "headers": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ },
+ "description": "Write-only. Request headers sent to the server, e.g. `Authorization`. Never returned on read."
+ },
+ "timeout": {
+ "type": "integer",
+ "minimum": 1000,
+ "maximum": 300000,
+ "description": "Per-request timeout in milliseconds. Defaults to 30000."
+ },
+ "retries": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 10,
+ "description": "Number of retries per request. Defaults to 3."
+ },
+ "enabled": {
+ "type": "boolean",
+ "description": "Whether the server's tools are available to workflows. Defaults to true."
+ },
+ "oauthClientId": {
+ "type": ["string", "null"],
+ "maxLength": 512,
+ "description": "Pre-registered OAuth client id for servers without dynamic client registration."
+ },
+ "oauthClientSecret": {
+ "type": ["string", "null"],
+ "maxLength": 2048,
+ "description": "Write-only. Pre-registered OAuth client secret. Never returned on read."
+ }
+ }
+ },
+ "UpdateMcpServerBody": {
+ "type": "object",
+ "description": "Fields to change on an existing MCP server. Omitted fields are left as they are.",
+ "additionalProperties": false,
+ "required": ["workspaceId"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace that owns the server."
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 2000
+ },
+ "transport": {
+ "type": "string",
+ "enum": ["streamable-http"]
+ },
+ "url": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 2048,
+ "description": "Immutable. Must equal the server's current URL — a different value returns `400`, because the server's id is derived from its URL."
+ },
+ "authType": {
+ "type": "string",
+ "enum": ["none", "headers", "oauth"]
+ },
+ "headers": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ },
+ "description": "Write-only. Replaces the stored header map wholesale."
+ },
+ "timeout": {
+ "type": "integer",
+ "minimum": 1000,
+ "maximum": 300000
+ },
+ "retries": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 10
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "oauthClientId": {
+ "type": ["string", "null"],
+ "maxLength": 512
+ },
+ "oauthClientSecret": {
+ "type": ["string", "null"],
+ "maxLength": 2048,
+ "description": "Write-only. Never returned on read."
+ }
+ }
+ },
+ "SkillSummary": {
+ "type": "object",
+ "description": "A skill without its body. Fetch the skill by id to read `content`.",
+ "required": ["id", "name", "description", "readOnly", "createdAt", "updatedAt"],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The skill's unique identifier."
+ },
+ "name": {
+ "type": "string",
+ "description": "Kebab-case name, unique within the workspace. This is what agents reference."
+ },
+ "description": {
+ "type": "string",
+ "description": "One-line summary of when the skill applies."
+ },
+ "readOnly": {
+ "type": "boolean",
+ "description": "True for built-in template skills, which ship with Sim and cannot be modified or deleted."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "Skill": {
+ "type": "object",
+ "description": "A skill, including its full body.",
+ "required": ["id", "name", "description", "content", "readOnly", "createdAt", "updatedAt"],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The skill's unique identifier."
+ },
+ "name": {
+ "type": "string",
+ "description": "Kebab-case name, unique within the workspace. This is what agents reference."
+ },
+ "description": {
+ "type": "string",
+ "description": "One-line summary of when the skill applies."
+ },
+ "content": {
+ "type": "string",
+ "description": "The skill body — the instructions handed to the agent."
+ },
+ "readOnly": {
+ "type": "boolean",
+ "description": "True for built-in template skills, which ship with Sim and cannot be modified or deleted."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "SkillData": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["skill"],
+ "properties": {
+ "skill": {
+ "$ref": "#/components/schemas/Skill"
+ }
+ }
+ }
+ }
+ },
+ "CreateSkillBody": {
+ "type": "object",
+ "description": "A new skill.",
+ "additionalProperties": false,
+ "required": ["workspaceId", "name", "description", "content"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace to create the skill in."
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 64,
+ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
+ "description": "Kebab-case name, unique within the workspace. Names reserved by built-in skills are rejected."
+ },
+ "description": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024,
+ "description": "One-line summary of when the skill applies."
+ },
+ "content": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 50000,
+ "description": "The skill body — the instructions handed to the agent."
+ }
+ }
+ },
+ "UpdateSkillBody": {
+ "type": "object",
+ "description": "Fields to change on an existing skill. At least one of `name`, `description`, or `content` is required; omitted fields keep their stored values.",
+ "additionalProperties": false,
+ "required": ["workspaceId"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace that owns the skill."
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 64,
+ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$"
+ },
+ "description": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024
+ },
+ "content": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 50000
+ }
+ }
+ },
+ "CustomToolSchema": {
+ "type": "object",
+ "description": "OpenAI-style function declaration describing the tool's callable surface. The parameter properties are caller-defined, so the shape below the function level is open.",
+ "required": ["type", "function"],
+ "properties": {
+ "type": {
+ "type": "string",
+ "const": "function"
+ },
+ "function": {
+ "type": "object",
+ "required": ["name", "parameters"],
+ "properties": {
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The function name the model calls."
+ },
+ "description": {
+ "type": "string",
+ "description": "What the tool does, shown to the model."
+ },
+ "parameters": {
+ "type": "object",
+ "description": "JSON Schema for the tool's arguments.",
+ "required": ["type", "properties"],
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "Usually `object`."
+ },
+ "properties": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "Caller-defined argument schemas, keyed by argument name."
+ },
+ "required": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Names of the required arguments."
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "CustomTool": {
+ "type": "object",
+ "description": "A code-backed tool defined in a workspace that agents can call.",
+ "required": ["id", "title", "schema", "code", "createdAt", "updatedAt"],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The tool's unique identifier."
+ },
+ "title": {
+ "type": "string",
+ "description": "Display title, unique within the workspace. Tools also resolve by title at call time."
+ },
+ "schema": {
+ "$ref": "#/components/schemas/CustomToolSchema"
+ },
+ "code": {
+ "type": "string",
+ "description": "The tool body, executed in Sim's sandboxed function runtime with the schema's parameters bound as variables."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "CustomToolData": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["customTool"],
+ "properties": {
+ "customTool": {
+ "$ref": "#/components/schemas/CustomTool"
+ }
+ }
+ }
+ }
+ },
+ "CreateCustomToolBody": {
+ "type": "object",
+ "description": "A new custom tool.",
+ "additionalProperties": false,
+ "required": ["workspaceId", "title", "schema", "code"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace to create the tool in."
+ },
+ "title": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 200,
+ "description": "Display title, unique within the workspace."
+ },
+ "schema": {
+ "$ref": "#/components/schemas/CustomToolSchema"
+ },
+ "code": {
+ "type": "string",
+ "maxLength": 100000,
+ "description": "The tool body, executed in Sim's sandboxed function runtime."
+ }
+ }
+ },
+ "UpdateCustomToolBody": {
+ "type": "object",
+ "description": "Fields to change on an existing custom tool. At least one of `title`, `schema`, or `code` is required; omitted fields keep their stored values.",
+ "additionalProperties": false,
+ "required": ["workspaceId"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace that owns the tool."
+ },
+ "title": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 200
+ },
+ "schema": {
+ "$ref": "#/components/schemas/CustomToolSchema"
+ },
+ "code": {
+ "type": "string",
+ "maxLength": 100000
+ }
+ }
+ },
+ "Credential": {
+ "type": "object",
+ "description": "A stored credential. Secret material is write-only and never appears here.",
+ "required": [
+ "id",
+ "type",
+ "displayName",
+ "description",
+ "providerId",
+ "accountId",
+ "envKey",
+ "hasServiceAccountKey",
+ "role",
+ "createdAt",
+ "updatedAt"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The credential's unique identifier."
+ },
+ "type": {
+ "type": "string",
+ "enum": ["oauth", "env_workspace", "env_personal", "service_account"],
+ "description": "What kind of credential this is."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Display name."
+ },
+ "description": {
+ "type": ["string", "null"]
+ },
+ "providerId": {
+ "type": ["string", "null"],
+ "description": "The integration this credential authenticates against, when it has one."
+ },
+ "accountId": {
+ "type": ["string", "null"],
+ "description": "The linked OAuth account, for `oauth` credentials."
+ },
+ "envKey": {
+ "type": ["string", "null"],
+ "description": "The environment-variable name, for `env_workspace` / `env_personal` credentials."
+ },
+ "hasServiceAccountKey": {
+ "type": "boolean",
+ "description": "Whether a service-account secret is stored. The secret itself is never returned."
+ },
+ "role": {
+ "type": "string",
+ "enum": ["admin", "member"],
+ "description": "The caller's role on this credential. Only admins can update or delete it."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "CredentialData": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["credential"],
+ "properties": {
+ "credential": {
+ "$ref": "#/components/schemas/Credential"
+ }
+ }
+ }
+ }
+ },
+ "CreateCredentialBody": {
+ "type": "object",
+ "description": "A new credential. Every secret field is write-only and is never returned.",
+ "additionalProperties": false,
+ "required": ["workspaceId", "type"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace to create the credential in."
+ },
+ "type": {
+ "type": "string",
+ "enum": ["env_workspace", "env_personal", "service_account"],
+ "description": "`oauth` is not creatable here — use the interactive OAuth connect flow."
+ },
+ "displayName": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255,
+ "description": "Display name. Derived from the env key or the verified provider account when omitted."
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 500
+ },
+ "providerId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Required for `service_account` — the integration the secret belongs to."
+ },
+ "envKey": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Required for env credentials. Letters, numbers, and underscores only; `{{NAME}}` is accepted and unwrapped."
+ },
+ "serviceAccountJson": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Write-only. Google-style service-account JSON key."
+ },
+ "signingSecret": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Write-only. Slack custom-bot signing secret."
+ },
+ "botToken": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Write-only. Slack custom-bot token."
+ },
+ "apiToken": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Write-only. Atlassian API token."
+ },
+ "domain": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Atlassian site domain, paired with `apiToken`."
+ },
+ "clientId": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 512
+ },
+ "clientSecret": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024,
+ "description": "Write-only. Client-credentials secret."
+ },
+ "orgId": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255
+ },
+ "dataCenter": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 32,
+ "description": "Optional provider region selector, such as a Zoho Desk data center."
+ }
+ }
+ },
+ "UpdateCredentialBody": {
+ "type": "object",
+ "description": "Fields to change on an existing credential. At least one field besides `workspaceId` is required. Sending a secret field rotates that secret in place; secrets are never returned.",
+ "additionalProperties": false,
+ "required": ["workspaceId"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace that owns the credential."
+ },
+ "displayName": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255
+ },
+ "description": {
+ "type": ["string", "null"],
+ "maxLength": 500,
+ "description": "Pass null to clear the description."
+ },
+ "serviceAccountJson": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Write-only. Replaces the stored service-account JSON key."
+ },
+ "signingSecret": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Write-only."
+ },
+ "botToken": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Write-only."
+ },
+ "apiToken": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Write-only."
+ },
+ "domain": {
+ "type": "string",
+ "minLength": 1
+ },
+ "clientId": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 512
+ },
+ "clientSecret": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024,
+ "description": "Write-only."
+ },
+ "orgId": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255
+ },
+ "dataCenter": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 32,
+ "description": "Optional provider region selector, such as a Zoho Desk data center."
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json
index 15757b19f1e..11d744c306f 100644
--- a/apps/docs/openapi-v2-tables.json
+++ b/apps/docs/openapi-v2-tables.json
@@ -2,378 +2,6541 @@
"openapi": "3.1.0",
"info": {
"title": "Sim Tables API v2",
- "version": "2.0.0-preview",
- "description": "Read access to Sim tables with the typed predicate filter grammar and opaque cursor pagination. This surface is feature-gated (`tables-v2-api`): when the flag is off for the caller, every endpoint returns 404 as if it does not exist. Filters are predicate trees — `{\"all\": [...]}` (AND) or `{\"any\": [...]}` (OR) groups whose members are `{field, op, value}` conditions or nested groups. Built-in columns `id`, `createdAt`, and `updatedAt` (camelCase) are filterable and sortable alongside user columns."
+ "description": "Version 2 of the Sim Tables API for managing tables, their column schemas, and rows of structured data. v2 standardizes every endpoint on a single response family: a single resource is returned as `{ data }`, lists are returned as `{ data, nextCursor }` with opaque cursor pagination, and errors are returned as `{ error: { code, message, details? } }`. Rate-limit state is carried in `X-RateLimit-*` response headers. Authenticate every request with the `X-API-Key` header. Row `data` is always keyed by column name.",
+ "version": "2.0.0",
+ "contact": {
+ "name": "Sim Support",
+ "email": "help@sim.ai",
+ "url": "https://www.sim.ai"
+ },
+ "license": {
+ "name": "Apache 2.0",
+ "url": "https://www.apache.org/licenses/LICENSE-2.0.html"
+ }
},
- "servers": [{ "url": "https://www.sim.ai" }],
- "security": [{ "apiKey": [] }],
+ "servers": [
+ {
+ "url": "https://www.sim.ai",
+ "description": "Production"
+ }
+ ],
+ "tags": [
+ {
+ "name": "Tables",
+ "description": "Manage tables, columns, and rows for structured data storage (v2 API)."
+ }
+ ],
+ "security": [
+ {
+ "apiKey": []
+ }
+ ],
"paths": {
"/api/v2/tables": {
"get": {
- "operationId": "v2ListTables",
+ "operationId": "listTables",
"summary": "List Tables",
- "description": "List every table in a workspace with its column schema and row count.",
- "tags": ["Tables v2"],
+ "description": "List all tables in a workspace. Returns the full bounded set of tables for the workspace as a single page, so `nextCursor` is always null.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
"parameters": [
{
- "name": "workspaceId",
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ },
+ {
+ "name": "folderPath",
"in": "query",
- "required": true,
- "schema": { "type": "string", "minLength": 1 }
+ "required": false,
+ "description": "Restrict the list to one folder. Omit to list every table in the workspace.",
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ }
+ },
+ {
+ "name": "search",
+ "in": "query",
+ "required": false,
+ "description": "Case-insensitive substring match against the table `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 200
+ }
+ },
+ {
+ "name": "sortBy",
+ "in": "query",
+ "required": false,
+ "description": "Field to sort by.",
+ "schema": {
+ "type": "string",
+ "enum": ["name", "createdAt", "updatedAt"],
+ "default": "createdAt"
+ }
+ },
+ {
+ "name": "sortOrder",
+ "in": "query",
+ "required": false,
+ "description": "Sort direction.",
+ "schema": {
+ "type": "string",
+ "enum": ["asc", "desc"],
+ "default": "asc"
+ }
+ },
+ {
+ "$ref": "#/components/parameters/LimitQuery"
+ },
+ {
+ "$ref": "#/components/parameters/CursorQuery"
}
],
"responses": {
"200": {
- "description": "Tables in the workspace. Served with `Cache-Control: private, no-store`.",
+ "description": "The tables in the workspace.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
"content": {
"application/json": {
"schema": {
- "type": "object",
- "required": ["success", "data"],
- "properties": {
- "success": { "const": true },
- "data": {
- "type": "object",
- "required": ["tables", "totalCount"],
- "properties": {
- "tables": {
- "type": "array",
- "items": { "$ref": "#/components/schemas/TableSummary" }
- },
- "totalCount": { "type": "integer" }
- }
- }
- }
+ "$ref": "#/components/schemas/TableListEnvelope"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "post": {
+ "operationId": "createTable",
+ "summary": "Create Table",
+ "description": "Create a new table with a typed column schema. The schema must contain between 1 and 50 columns.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"contacts\",\n \"description\": \"Customer contacts\",\n \"schema\": {\n \"columns\": [\n { \"name\": \"email\", \"type\": \"string\", \"required\": true, \"unique\": true },\n { \"name\": \"name\", \"type\": \"string\", \"required\": true },\n { \"name\": \"age\", \"type\": \"number\" }\n ]\n }\n }'"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "The table name, optional description, column schema, and target workspace.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateTableBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "The table was created.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TableEnvelope"
}
}
}
},
- "400": { "$ref": "#/components/responses/ValidationError" },
- "401": { "$ref": "#/components/responses/Unauthorized" },
- "403": { "$ref": "#/components/responses/Forbidden" },
- "404": { "$ref": "#/components/responses/NotFoundOrGated" },
- "429": { "$ref": "#/components/responses/RateLimited" }
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
}
}
},
- "/api/v2/tables/{tableId}/query": {
- "post": {
- "operationId": "v2QueryTableRows",
- "summary": "Query Rows",
- "description": "Query rows with a typed predicate filter, an ordered sort spec, and opaque cursor pagination. Row `data` is keyed by column NAME; `select` cells return option names, and filter operands on select columns accept option names (resolved case-insensitively).\n\n**Pagination contract:** page by passing the previous response's `nextCursor` back as `cursor`, and stop only when it is `null` — a page may return fewer than `limit` rows and still have more behind it, so page fullness is never a termination signal. A cursor is bound to the exact query shape it was minted under: keyset cursors to the default row order, offset cursors (sorted views) to that sort. Replaying one under a different `sort` returns 400 `CURSOR_SORT_CONFLICT`. `totalCount` is computed on the first page only (requests with a `cursor` return `totalCount: null`).",
- "tags": ["Tables v2"],
+ "/api/v2/tables/{tableId}": {
+ "get": {
+ "operationId": "getTable",
+ "summary": "Get Table",
+ "description": "Get a single table's metadata and column schema.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
"parameters": [
{
- "name": "tableId",
- "in": "path",
- "required": true,
- "schema": { "type": "string", "minLength": 1 }
+ "$ref": "#/components/parameters/TableId"
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The requested table.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TableEnvelope"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "delete": {
+ "operationId": "deleteTable",
+ "summary": "Delete Table",
+ "description": "Delete a table. Returns the id and an explicit deletion confirmation.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The table was deleted.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DeleteTableEnvelope"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "patch": {
+ "operationId": "updateTable",
+ "summary": "Update Table",
+ "description": "Rename a table, edit its description, and/or move it between folders. Provide at least one of `name`, `description`, or `folderPath`. Each field is applied independently, so one request can combine changes and the response reflects every applied change.\n\nAll three fields need workspace write.\n\n**Lock flags are read-only here.** A table's `locks` are returned on the table resource and enforced on every write (a locked verb returns 423), but they cannot be changed through the API — a write-level key must not be able to clear the guard placed there to stop it. Changing a lock is a first-party workspace-admin action. A request carrying `locks` is rejected with 400 rather than silently ignored.\n\n**Partial-success semantics.** The operations commit independently, so this endpoint is not atomic. Everything that can be *rejected* — the body shape, folder existence — is validated before the first write, so a rejected request changes nothing. If a genuine fault (a lost race, the table deleted mid-request, a database error) fails a later operation after an earlier one has committed, the response is an error whose `error.details.applied` lists the operations that are nevertheless live (`\"name\"`, `\"description\"`, `\"folderPath\"`). The field is absent when nothing was applied, so its presence always means \"these changes took effect despite the error\" — re-read the table to confirm before retrying.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"name\":\"customers\"}'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
}
],
"requestBody": {
"required": true,
- "description": "Bodies over 1 MB are rejected with 413.",
"content": {
"application/json": {
"schema": {
- "type": "object",
- "required": ["workspaceId"],
- "properties": {
- "workspaceId": { "type": "string", "minLength": 1 },
- "predicate": { "$ref": "#/components/schemas/Predicate" },
- "sort": {
- "type": "array",
- "maxItems": 16,
- "description": "Ordered sort spec, highest priority first.",
- "items": {
- "type": "object",
- "required": ["field", "direction"],
- "properties": {
- "field": { "type": "string" },
- "direction": { "enum": ["asc", "desc"] }
- }
- }
- },
- "limit": {
- "type": "integer",
- "minimum": 0,
- "maximum": 1000,
- "default": 100,
- "description": "Omitted → 100. `1..1000` → page size. `0` → the ENTIRE matching result in one response; fails with 400 `TABLE_QUERY_RESULT_TOO_LARGE` if it exceeds the 5 MB row-data budget (narrow the predicate or page instead)."
- },
- "cursor": {
- "type": "string",
- "description": "Opaque token from a previous response's `nextCursor`. Pass back verbatim. Mutually exclusive with `sort`."
- }
- }
+ "$ref": "#/components/schemas/UpdateTableBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The updated table.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
},
- "examples": {
- "filtered": {
- "summary": "Multi-select membership + negated pattern",
- "value": {
- "workspaceId": "ws_123",
- "predicate": {
- "all": [
- { "field": "Color", "op": "contains", "value": "Purple" },
- { "field": "name", "op": "nlike", "value": "G*" }
- ]
- },
- "limit": 100
- }
- },
- "builtinColumns": {
- "summary": "Built-in column range (UTC, timezone-independent)",
- "value": {
- "workspaceId": "ws_123",
- "predicate": {
- "all": [
- { "field": "createdAt", "op": "gte", "value": "2026-07-24T03:00:00.000Z" },
- { "field": "createdAt", "op": "lte", "value": "2026-07-25T02:59:59.999Z" }
- ]
- }
- }
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TableEnvelope"
}
}
}
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "423": {
+ "$ref": "#/components/responses/Locked"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/tables/{tableId}/columns": {
+ "post": {
+ "operationId": "addTableColumn",
+ "summary": "Add Column",
+ "description": "Add a column to the table schema. Returns the table's full column list after the change.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/columns\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"column\": {\n \"name\": \"phone\",\n \"type\": \"string\",\n \"required\": false,\n \"unique\": false\n }\n }'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "The workspace and the column definition to add.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AddColumnBody"
+ }
+ }
}
},
"responses": {
"200": {
- "description": "A page of rows. Served with `Cache-Control: private, no-store`.",
+ "description": "The column was added.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
"content": {
"application/json": {
"schema": {
- "type": "object",
- "required": ["success", "data"],
- "properties": {
- "success": { "const": true },
- "data": {
- "type": "object",
- "required": ["rows", "rowCount", "nextCursor"],
- "properties": {
- "rows": {
- "type": "array",
- "items": {
- "type": "object",
- "required": ["id", "data", "createdAt", "updatedAt"],
- "properties": {
- "id": { "type": "string" },
- "data": {
- "type": "object",
- "description": "Column-NAME-keyed cell values.",
- "additionalProperties": true
- },
- "createdAt": { "type": "string", "format": "date-time" },
- "updatedAt": { "type": "string", "format": "date-time" }
- }
- }
- },
- "rowCount": { "type": "integer", "description": "Rows in THIS page." },
- "totalCount": {
- "type": ["integer", "null"],
- "description": "Rows matching the predicate across all pages. First page only; null when a cursor was supplied."
- },
- "limit": { "type": ["integer", "null"] },
- "nextCursor": {
- "type": ["string", "null"],
- "description": "Non-null ⇒ more rows exist. The ONLY termination signal is null."
- }
+ "$ref": "#/components/schemas/ColumnsEnvelope"
+ },
+ "example": {
+ "data": {
+ "columns": [
+ {
+ "id": "col_a1b2c3",
+ "name": "email",
+ "type": "string",
+ "required": true,
+ "unique": true
+ },
+ {
+ "id": "col_d4e5f6",
+ "name": "name",
+ "type": "string",
+ "required": true,
+ "unique": false
+ },
+ {
+ "id": "col_x9y8z7",
+ "name": "phone",
+ "type": "string",
+ "required": false,
+ "unique": false
}
- }
+ ]
}
}
}
}
},
- "400": {
- "description": "Validation failure. Machine-readable `code` values include `INVALID_FILTER` (unknown column, operator/type mismatch, malformed tree), `INVALID_ORDER`, `INVALID_CURSOR`, `CURSOR_SORT_CONFLICT`, and `TABLE_QUERY_RESULT_TOO_LARGE` (unbounded result exceeded the 5 MB budget).",
- "content": {
- "application/json": {
- "schema": { "$ref": "#/components/schemas/ErrorBody" }
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "patch": {
+ "operationId": "updateTableColumn",
+ "summary": "Update Column",
+ "description": "Update a column by name — rename it, change its type, or toggle its required/unique constraints. Provide at least one field in `updates`. Returns the table's full column list after the change.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/columns\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"columnName\": \"phone\",\n \"updates\": {\n \"name\": \"phone_number\",\n \"required\": true\n }\n }'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "The workspace, the current column name, and the fields to change.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateColumnBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The column was updated.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ColumnsEnvelope"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "delete": {
+ "operationId": "deleteTableColumn",
+ "summary": "Delete Column",
+ "description": "Delete a column from the table schema by name. A table must always keep at least one column. Returns the table's full column list after the change.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/columns\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"columnName\": \"phone_number\"\n }'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "The workspace and the name of the column to delete.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DeleteColumnBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The column was deleted.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ColumnsEnvelope"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/tables/{tableId}/rows": {
+ "get": {
+ "operationId": "listTableRows",
+ "summary": "List rows",
+ "description": "Plain cursor page over the default row order. Filtering and sorting are not part of this surface — use `POST /api/v2/tables/{tableId}/query` for predicate-filtered, sorted reads. The cursor is opaque; page by passing the previous response's `nextCursor` back as `cursor` and stop when it is `null`.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows?workspaceId=YOUR_WORKSPACE_ID&limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ },
+ {
+ "$ref": "#/components/parameters/LimitQuery"
+ },
+ {
+ "$ref": "#/components/parameters/CursorQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Rows matching the query.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RowListEnvelope"
+ },
+ "example": {
+ "data": [
+ {
+ "id": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07",
+ "data": {
+ "email": "jane@example.com",
+ "name": "Jane Doe",
+ "age": 30
+ },
+ "createdAt": "2026-01-15T10:30:00.000Z",
+ "updatedAt": "2026-01-15T10:30:00.000Z"
+ }
+ ],
+ "nextCursor": "eyJvZmZzZXQiOjUwfQ=="
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "post": {
+ "operationId": "createTableRows",
+ "summary": "Create Rows",
+ "description": "Insert one or many rows. Send a single-row body (`{ data }`) to insert one row, or a batch body (`{ rows }`) to insert up to 1000 rows in one request. The response shape mirrors the request: a single insert returns `{ data: { row } }`, a batch insert returns `{ data: { rows, insertedCount } }`. Row `data` is keyed by column name.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"data\": {\n \"email\": \"user@example.com\",\n \"name\": \"Jane Doe\"\n }\n }'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "Either a single-row payload or a batch payload.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateRowsBody"
+ },
+ "examples": {
+ "single": {
+ "summary": "Insert a single row",
+ "value": {
+ "workspaceId": "YOUR_WORKSPACE_ID",
+ "data": {
+ "email": "user@example.com",
+ "name": "Jane Doe"
+ }
+ }
+ },
+ "batch": {
+ "summary": "Insert multiple rows",
+ "value": {
+ "workspaceId": "YOUR_WORKSPACE_ID",
+ "rows": [
+ {
+ "email": "a@example.com",
+ "name": "Ada"
+ },
+ {
+ "email": "b@example.com",
+ "name": "Babbage"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The row(s) were inserted.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateRowsResponse"
+ },
+ "examples": {
+ "single": {
+ "summary": "Single insert response",
+ "value": {
+ "data": {
+ "row": {
+ "id": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07",
+ "data": {
+ "email": "user@example.com",
+ "name": "Jane Doe"
+ },
+ "createdAt": "2026-01-15T10:30:00.000Z",
+ "updatedAt": "2026-01-15T10:30:00.000Z"
+ }
+ }
+ }
+ },
+ "batch": {
+ "summary": "Batch insert response",
+ "value": {
+ "data": {
+ "rows": [
+ {
+ "id": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93",
+ "data": {
+ "email": "a@example.com",
+ "name": "Ada"
+ },
+ "createdAt": "2026-01-15T10:30:00.000Z",
+ "updatedAt": "2026-01-15T10:30:00.000Z"
+ },
+ {
+ "id": "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85",
+ "data": {
+ "email": "b@example.com",
+ "name": "Babbage"
+ },
+ "createdAt": "2026-01-15T10:30:00.000Z",
+ "updatedAt": "2026-01-15T10:30:00.000Z"
+ }
+ ],
+ "insertedCount": 2
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "put": {
+ "operationId": "updateTableRows",
+ "summary": "Update Rows by Filter",
+ "description": "Bulk-update every row matching a filter, applying the same partial `data` patch to each. The filter must contain at least one condition. `updatedRowIds` is always returned (empty when nothing matched).",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X PUT \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"filter\": { \"status\": \"pending\" },\n \"data\": { \"status\": \"active\" }\n }'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "The workspace, a non-empty filter, the patch data, and an optional row cap.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateRowsByFilterBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The matching rows were updated.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateRowsEnvelope"
+ },
+ "example": {
+ "data": {
+ "updatedCount": 3,
+ "updatedRowIds": [
+ "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93",
+ "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85",
+ "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07"
+ ]
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "delete": {
+ "operationId": "deleteTableRows",
+ "summary": "Delete Rows",
+ "description": "Delete rows in bulk, either by a non-empty filter or by an explicit list of row ids. Provide exactly one of `filter` or `rowIds`. For id-based deletes the response also reports `requestedCount` and any `missingRowIds`; these fields are omitted for filter-based deletes.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"rowIds\": [\"row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93\", \"row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85\"]\n }'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "The workspace and either a non-empty filter or an explicit list of row ids.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DeleteRowsBody"
+ },
+ "examples": {
+ "byIds": {
+ "summary": "Delete specific rows by id",
+ "value": {
+ "workspaceId": "YOUR_WORKSPACE_ID",
+ "rowIds": [
+ "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93",
+ "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85"
+ ]
+ }
+ },
+ "byFilter": {
+ "summary": "Delete rows matching a filter",
+ "value": {
+ "workspaceId": "YOUR_WORKSPACE_ID",
+ "filter": {
+ "all": [
+ {
+ "field": "status",
+ "op": "eq",
+ "value": "archived"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The rows were deleted.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DeleteRowsEnvelope"
+ },
+ "examples": {
+ "byIds": {
+ "summary": "Id-based delete response",
+ "value": {
+ "data": {
+ "deletedCount": 2,
+ "deletedRowIds": [
+ "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93",
+ "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85"
+ ],
+ "requestedCount": 2,
+ "missingRowIds": []
+ }
+ }
+ },
+ "byFilter": {
+ "summary": "Filter-based delete response",
+ "value": {
+ "data": {
+ "deletedCount": 5,
+ "deletedRowIds": ["row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93"]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/tables/{tableId}/rows/{rowId}": {
+ "get": {
+ "operationId": "getTableRow",
+ "summary": "Get Row",
+ "description": "Get a single row by id.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/{rowId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ },
+ {
+ "$ref": "#/components/parameters/RowId"
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The requested row.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RowEnvelope"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "patch": {
+ "operationId": "updateTableRow",
+ "summary": "Update Row",
+ "description": "Partially update a single row by id. The `data` patch is keyed by column name and merges into the existing row.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/{rowId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"data\": { \"name\": \"Updated Name\" }\n }'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ },
+ {
+ "$ref": "#/components/parameters/RowId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "The workspace and the partial row data to apply.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateRowBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The row was updated.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RowEnvelope"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "delete": {
+ "operationId": "deleteTableRow",
+ "summary": "Delete Row",
+ "description": "Delete a single row by id. Returns `deletedCount` and `deletedRowIds`, mirroring the bulk delete shape.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/{rowId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ },
+ {
+ "$ref": "#/components/parameters/RowId"
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The row was deleted.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DeleteRowEnvelope"
+ },
+ "example": {
+ "data": {
+ "deletedCount": 1,
+ "deletedRowIds": ["row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07"]
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/tables/{tableId}/rows/upsert": {
+ "post": {
+ "operationId": "upsertTableRow",
+ "summary": "Upsert Row",
+ "description": "Insert a row, or update the existing row that conflicts on a unique column. When `conflictTarget` is omitted the server resolves the conflict against the table's single unique column. The response reports whether the row was inserted or updated.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/upsert\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"data\": { \"email\": \"user@example.com\", \"name\": \"John\" },\n \"conflictTarget\": \"email\"\n }'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "The workspace, the row data, and an optional unique column to resolve the conflict against.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpsertRowBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The row was inserted or updated.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpsertRowEnvelope"
+ },
+ "example": {
+ "data": {
+ "row": {
+ "id": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07",
+ "data": {
+ "email": "user@example.com",
+ "name": "John"
+ },
+ "createdAt": "2026-01-15T10:30:00.000Z",
+ "updatedAt": "2026-01-15T10:30:00.000Z"
+ },
+ "operation": "insert"
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/tables/{tableId}/query": {
+ "post": {
+ "operationId": "queryTableRows",
+ "summary": "Query Rows",
+ "description": "Query rows with a typed predicate filter, an ordered sort spec, and opaque cursor pagination. Row `data` is keyed by column NAME; `select` cells return option names, and filter operands on select columns accept option names (resolved case-insensitively).\n\n**Pagination contract:** page by passing the previous response's `nextCursor` back as `cursor`, and stop only when it is `null` — a page may return fewer than `limit` rows and still have more behind it, so page fullness is never a termination signal. A cursor is bound to the exact query shape it was minted under: keyset cursors to the default row order, offset cursors (sorted views) to that sort. Replaying one under a different `sort` returns 400 `CURSOR_SORT_CONFLICT`.",
+ "tags": ["Tables"],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "Bodies over 1 MB are rejected with 413.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["workspaceId"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1
+ },
+ "predicate": {
+ "$ref": "#/components/schemas/Predicate"
+ },
+ "sort": {
+ "type": "array",
+ "maxItems": 16,
+ "description": "Ordered sort spec, highest priority first.",
+ "items": {
+ "type": "object",
+ "required": ["field", "direction"],
+ "properties": {
+ "field": {
+ "type": "string"
+ },
+ "direction": {
+ "enum": ["asc", "desc"]
+ }
+ }
+ }
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 1000,
+ "default": 100,
+ "description": "Omitted → 100. `1..1000` → page size. `0` → the ENTIRE matching result in one response; fails with 400 `TABLE_QUERY_RESULT_TOO_LARGE` if it exceeds the 5 MB row-data budget (narrow the predicate or page instead)."
+ },
+ "cursor": {
+ "type": "string",
+ "description": "Opaque token from a previous response's `nextCursor`. Pass back verbatim. Mutually exclusive with `sort`."
+ }
+ }
+ },
+ "examples": {
+ "filtered": {
+ "summary": "Multi-select membership + negated pattern",
+ "value": {
+ "workspaceId": "ws_123",
+ "predicate": {
+ "all": [
+ {
+ "field": "Color",
+ "op": "contains",
+ "value": "Purple"
+ },
+ {
+ "field": "name",
+ "op": "nlike",
+ "value": "G*"
+ }
+ ]
+ },
+ "limit": 100
+ }
+ },
+ "builtinColumns": {
+ "summary": "Built-in column range (UTC, timezone-independent)",
+ "value": {
+ "workspaceId": "ws_123",
+ "predicate": {
+ "all": [
+ {
+ "field": "createdAt",
+ "op": "gte",
+ "value": "2026-07-24T03:00:00.000Z"
+ },
+ {
+ "field": "createdAt",
+ "op": "lte",
+ "value": "2026-07-25T02:59:59.999Z"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "A page of matching rows. Served with `Cache-Control: private, no-store`.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RowListEnvelope"
+ },
+ "example": {
+ "data": [
+ {
+ "id": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07",
+ "data": {
+ "name": "Jane Doe",
+ "status": "active"
+ },
+ "createdAt": "2026-01-15T10:30:00.000Z",
+ "updatedAt": "2026-01-15T10:30:00.000Z"
+ }
+ ],
+ "nextCursor": null
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "413": {
+ "description": "Request body exceeds the 1 MB limit."
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/tables/{tableId}/views": {
+ "get": {
+ "operationId": "listTableViews",
+ "summary": "List Views",
+ "description": "Every saved view on the table, oldest first. A table carries a bounded set of views, so this is a single full page and `nextCursor` is always null. References to columns that no longer exist are pruned from each config on read.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/views?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The table’s saved views.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ViewListEnvelope"
+ },
+ "example": {
+ "data": [
+ {
+ "id": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e",
+ "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14",
+ "name": "Active customers",
+ "config": {
+ "hiddenColumns": ["col_x9y8z7"],
+ "filter": {
+ "all": [
+ {
+ "field": "col_a1b2c3",
+ "op": "eq",
+ "value": "active"
+ }
+ ]
+ },
+ "sort": [
+ {
+ "field": "col_d4e5f6",
+ "direction": "desc"
+ }
+ ]
+ },
+ "isDefault": true,
+ "createdBy": "user_2f8c1a4e6b0d47539ac1e3b5d7f90248",
+ "createdAt": "2026-01-15T10:30:00.000Z",
+ "updatedAt": "2026-01-16T09:12:00.000Z"
+ }
+ ],
+ "nextCursor": null
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "post": {
+ "operationId": "createTableView",
+ "summary": "Create View",
+ "description": "Save a filter, sort, and column layout as a named view. A view is presentation state, never an access boundary — rows it hides stay readable through the row and query endpoints.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/views\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"name\":\"Active customers\",\"config\":{}}'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateViewBody"
+ },
+ "example": {
+ "workspaceId": "ws_123",
+ "name": "Active customers",
+ "config": {
+ "filter": {
+ "all": [
+ {
+ "field": "col_a1b2c3",
+ "op": "eq",
+ "value": "active"
+ }
+ ]
+ },
+ "sort": [
+ {
+ "field": "col_d4e5f6",
+ "direction": "desc"
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "The created view.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ViewEnvelope"
+ },
+ "example": {
+ "data": {
+ "view": {
+ "id": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e",
+ "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14",
+ "name": "Active customers",
+ "config": {
+ "hiddenColumns": ["col_x9y8z7"],
+ "filter": {
+ "all": [
+ {
+ "field": "col_a1b2c3",
+ "op": "eq",
+ "value": "active"
+ }
+ ]
+ },
+ "sort": [
+ {
+ "field": "col_d4e5f6",
+ "direction": "desc"
+ }
+ ]
+ },
+ "isDefault": true,
+ "createdBy": "user_2f8c1a4e6b0d47539ac1e3b5d7f90248",
+ "createdAt": "2026-01-15T10:30:00.000Z",
+ "updatedAt": "2026-01-16T09:12:00.000Z"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/tables/{tableId}/views/{viewId}": {
+ "get": {
+ "operationId": "getTableView",
+ "summary": "Get View",
+ "description": "One saved view, with references to deleted columns pruned from its config.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/views/{viewId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ },
+ {
+ "$ref": "#/components/parameters/ViewId"
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The requested view.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ViewEnvelope"
+ },
+ "example": {
+ "data": {
+ "view": {
+ "id": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e",
+ "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14",
+ "name": "Active customers",
+ "config": {
+ "hiddenColumns": ["col_x9y8z7"],
+ "filter": {
+ "all": [
+ {
+ "field": "col_a1b2c3",
+ "op": "eq",
+ "value": "active"
+ }
+ ]
+ },
+ "sort": [
+ {
+ "field": "col_d4e5f6",
+ "direction": "desc"
+ }
+ ]
+ },
+ "isDefault": true,
+ "createdBy": "user_2f8c1a4e6b0d47539ac1e3b5d7f90248",
+ "createdAt": "2026-01-15T10:30:00.000Z",
+ "updatedAt": "2026-01-16T09:12:00.000Z"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "patch": {
+ "operationId": "updateTableView",
+ "summary": "Update View",
+ "description": "Rename a view, replace or merge its config, or promote it to the table’s default. Provide at least one of `name`, `config`, `configPatch`, or `isDefault`.\n\n`config` replaces the stored config wholesale; `configPatch` is shallow-merged server-side so overlapping partial writes cannot clobber each other. The two are mutually exclusive. Setting `isDefault: true` demotes the table’s existing default in the same transaction.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/views/{viewId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"isDefault\":true}'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ },
+ {
+ "$ref": "#/components/parameters/ViewId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateViewBody"
+ },
+ "examples": {
+ "promote": {
+ "summary": "Make this the table’s default view",
+ "value": {
+ "workspaceId": "ws_123",
+ "isDefault": true
+ }
+ },
+ "replaceConfig": {
+ "summary": "Replace the saved filter",
+ "value": {
+ "workspaceId": "ws_123",
+ "config": {
+ "filter": {
+ "any": [
+ {
+ "field": "col_a1b2c3",
+ "op": "isNotEmpty"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The updated view.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ViewEnvelope"
+ },
+ "example": {
+ "data": {
+ "view": {
+ "id": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e",
+ "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14",
+ "name": "Active customers",
+ "config": {
+ "hiddenColumns": ["col_x9y8z7"],
+ "filter": {
+ "all": [
+ {
+ "field": "col_a1b2c3",
+ "op": "eq",
+ "value": "active"
+ }
+ ]
+ },
+ "sort": [
+ {
+ "field": "col_d4e5f6",
+ "direction": "desc"
+ }
+ ]
+ },
+ "isDefault": true,
+ "createdBy": "user_2f8c1a4e6b0d47539ac1e3b5d7f90248",
+ "createdAt": "2026-01-15T10:30:00.000Z",
+ "updatedAt": "2026-01-16T09:12:00.000Z"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "delete": {
+ "operationId": "deleteTableView",
+ "summary": "Delete View",
+ "description": "Remove a saved view. Deleting the table’s default simply leaves the table unfiltered; no rows are affected.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/views/{viewId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ },
+ {
+ "$ref": "#/components/parameters/ViewId"
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The view was deleted.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DeleteViewEnvelope"
+ },
+ "example": {
+ "data": {
+ "id": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e"
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/tables/{tableId}/groups": {
+ "get": {
+ "operationId": "listTableWorkflowGroups",
+ "summary": "List Workflow Groups",
+ "description": "The table’s workflow and enrichment groups — the units the run endpoints dispatch. Read-only: groups are authored in the workflow builder. Bounded per table, so this is a single full page and `nextCursor` is always null.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/groups?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The table’s workflow groups.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WorkflowGroupListEnvelope"
+ },
+ "example": {
+ "data": [
+ {
+ "id": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204",
+ "workflowId": "wf_1b3d5f7a9c2e4680b4d6f8a0c2e4b619",
+ "name": "Enrich company",
+ "type": "manual",
+ "dependencies": {
+ "columns": ["col_a1b2c3"]
+ },
+ "outputs": [
+ {
+ "blockId": "blk_agent1",
+ "path": "content",
+ "columnName": "summary"
+ }
+ ],
+ "deploymentMode": "deployed",
+ "autoRun": true
+ }
+ ],
+ "nextCursor": null
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "post": {
+ "operationId": "addTableWorkflowGroup",
+ "summary": "Add Workflow Group",
+ "description": "Bind a workflow or enrichment to the table and create the columns its runs populate, in one call.\n\nThe group is the unit that fills columns — one group can feed several. `group.outputs[].columnName` says where each value lands; `outputColumns` defines the columns to create. Every `outputColumns` entry must be named by an output, or the request is rejected rather than creating a column nothing feeds.\n\n`autoRun` defaults to **false**: enabling it backfills every existing row, which on an API key is a metered fan-out from a single call.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/groups\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"group\": {\n \"workflowId\": \"wf_...\",\n \"outputs\": [{\"blockId\": \"blk_7f2a\", \"path\": \"output.revenue\", \"columnName\": \"revenue\"}]\n },\n \"outputColumns\": [{\"name\": \"revenue\", \"type\": \"currency\"}]\n }'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AddWorkflowGroupBody"
+ },
+ "examples": {
+ "workflow": {
+ "summary": "Workflow-backed column",
+ "value": {
+ "workspaceId": "ws_123",
+ "group": {
+ "workflowId": "wf_1b3d5f7a9c2e4680b4d6f8a0c2e4b619",
+ "name": "Enrich company",
+ "outputs": [
+ {
+ "blockId": "blk_7f2a",
+ "path": "output.revenue",
+ "columnName": "revenue"
+ }
+ ]
+ },
+ "outputColumns": [
+ {
+ "name": "revenue",
+ "type": "currency"
+ }
+ ]
+ }
+ },
+ "enrichment": {
+ "summary": "Registry enrichment filling two columns",
+ "value": {
+ "workspaceId": "ws_123",
+ "group": {
+ "type": "enrichment",
+ "enrichmentId": "company_lookup",
+ "outputs": [
+ {
+ "outputId": "annual_revenue",
+ "columnName": "revenue"
+ },
+ {
+ "outputId": "headquarters",
+ "columnName": "hq"
+ }
+ ]
+ },
+ "outputColumns": [
+ {
+ "name": "revenue",
+ "type": "currency"
+ },
+ {
+ "name": "hq",
+ "type": "string"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "The created group and the table's columns.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WorkflowGroupEnvelope"
+ },
+ "example": {
+ "data": {
+ "group": {
+ "id": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204",
+ "workflowId": "wf_1b3d5f7a9c2e4680b4d6f8a0c2e4b619",
+ "name": "Enrich company",
+ "type": "manual",
+ "outputs": [
+ {
+ "blockId": "blk_7f2a",
+ "path": "output.revenue",
+ "columnName": "revenue"
+ }
+ ],
+ "deploymentMode": "deployed",
+ "autoRun": true
+ },
+ "columns": [
+ {
+ "id": "col_a1b2c3",
+ "name": "revenue",
+ "type": "currency",
+ "required": false,
+ "unique": false
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "423": {
+ "$ref": "#/components/responses/Locked"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "patch": {
+ "operationId": "updateTableWorkflowGroup",
+ "summary": "Update Workflow Group",
+ "description": "Restructure a group: re-point it at a different workflow, add or remove outputs, or change how its runs are scheduled.\n\n**Removing an output deletes that column and its values.** There is currently no way to detach a column from its group while keeping the data.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/groups\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"groupId\": \"grp_...\", \"name\": \"Renamed\"}'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateWorkflowGroupBody"
+ },
+ "examples": {
+ "rename": {
+ "summary": "Rename",
+ "value": {
+ "workspaceId": "ws_123",
+ "groupId": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204",
+ "name": "Renamed"
+ }
+ },
+ "addOutput": {
+ "summary": "Add a second output column",
+ "value": {
+ "workspaceId": "ws_123",
+ "groupId": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204",
+ "outputs": [
+ {
+ "blockId": "blk_7f2a",
+ "path": "output.revenue",
+ "columnName": "revenue"
+ },
+ {
+ "blockId": "blk_7f2a",
+ "path": "output.hq",
+ "columnName": "hq"
+ }
+ ],
+ "newOutputColumns": [
+ {
+ "name": "hq",
+ "type": "string"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The updated group and the table's columns.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WorkflowGroupEnvelope"
+ },
+ "example": {
+ "data": {
+ "group": {
+ "id": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204",
+ "workflowId": "wf_1b3d5f7a9c2e4680b4d6f8a0c2e4b619",
+ "name": "Enrich company",
+ "type": "manual",
+ "outputs": [
+ {
+ "blockId": "blk_7f2a",
+ "path": "output.revenue",
+ "columnName": "revenue"
+ }
+ ],
+ "deploymentMode": "deployed",
+ "autoRun": true
+ },
+ "columns": [
+ {
+ "id": "col_a1b2c3",
+ "name": "revenue",
+ "type": "currency",
+ "required": false,
+ "unique": false
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "423": {
+ "$ref": "#/components/responses/Locked"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "delete": {
+ "operationId": "deleteTableWorkflowGroup",
+ "summary": "Delete Workflow Group",
+ "description": "Remove a group **and every column it fed**, along with their values. The surviving column list is returned so a caller does not have to re-read the table.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/groups\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"groupId\": \"grp_...\"}'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DeleteWorkflowGroupBody"
+ },
+ "example": {
+ "workspaceId": "ws_123",
+ "groupId": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The group was removed.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DeleteWorkflowGroupEnvelope"
+ },
+ "example": {
+ "data": {
+ "id": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204",
+ "deleted": true,
+ "columns": []
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "423": {
+ "$ref": "#/components/responses/Locked"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/tables/{tableId}/columns/run": {
+ "post": {
+ "operationId": "runTableColumns",
+ "summary": "Run Column Groups",
+ "description": "Run one or more workflow or enrichment groups and write their outputs into the table.\n\n**Asynchronous.** The response acknowledges the dispatch, not the results: the runner walks the scoped rows and writes cells as runs land. Poll `POST /api/v2/tables/{tableId}/query` for results, and stop an in-flight run with the same group ids and an empty scope.\n\nScope with `rowIds` (an explicit set) or `filter` (every matching row, walked in pages so no id list is materialized) — never both. Omit both to run every row. Starting a run clears the target groups’ cells to pending, so a read taken immediately after will show them empty.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/columns/run\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"groupIds\":[\"grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204\"]}'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RunColumnBody"
+ },
+ "examples": {
+ "everyRow": {
+ "summary": "Run a group across the whole table",
+ "value": {
+ "workspaceId": "ws_123",
+ "groupIds": ["grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204"]
+ }
+ },
+ "backfillFiltered": {
+ "summary": "Backfill only unfinished rows matching a predicate, capped at 500",
+ "value": {
+ "workspaceId": "ws_123",
+ "groupIds": ["grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204"],
+ "runMode": "incomplete",
+ "filter": {
+ "all": [
+ {
+ "field": "status",
+ "op": "eq",
+ "value": "active"
+ }
+ ]
+ },
+ "limit": {
+ "type": "rows",
+ "max": 500
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The run was dispatched.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RunEnvelope"
+ },
+ "example": {
+ "data": {
+ "dispatchId": "dsp_4e6a8c0b2d1f4735896a0c2e4b6d8f13"
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "423": {
+ "$ref": "#/components/responses/Locked"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/tables/{tableId}/rows/{rowId}/enrichment/{groupId}": {
+ "post": {
+ "operationId": "runRowEnrichment",
+ "summary": "Run Enrichment For One Row",
+ "description": "The single-cell case of `POST /api/v2/tables/{tableId}/columns/run`: runs one group for one row. Naming a specific cell is an explicit re-run request, so an already-populated cell recomputes rather than being skipped.\n\n**Asynchronous** — the response acknowledges the dispatch; read the row back for the result.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/{rowId}/enrichment/{groupId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\"}'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ },
+ {
+ "$ref": "#/components/parameters/RowId"
+ },
+ {
+ "$ref": "#/components/parameters/GroupId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WorkspaceScopedBody"
+ },
+ "example": {
+ "workspaceId": "ws_123"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The run was dispatched.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RunEnvelope"
+ },
+ "example": {
+ "data": {
+ "dispatchId": "dsp_4e6a8c0b2d1f4735896a0c2e4b6d8f13"
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "423": {
+ "$ref": "#/components/responses/Locked"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/tables/{tableId}/rows/find": {
+ "post": {
+ "operationId": "findTableRows",
+ "summary": "Find Rows",
+ "description": "Case-insensitive substring search across every cell, narrowed by the same predicate and sort grammar as `POST /api/v2/tables/{tableId}/query`. Select cells match on their option names.\n\nReturns matching **cells**, not rows. Each match carries the row’s ordinal in exactly the view a query with the same `predicate` and `sort` returns, so a caller can page straight to it. Matches are capped server-side and have no cursor — when `truncated` is true, narrow the predicate rather than paging.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/find\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"q\":\"acme\"}'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/FindRowsBody"
+ },
+ "examples": {
+ "wholeTable": {
+ "summary": "Search every cell",
+ "value": {
+ "workspaceId": "ws_123",
+ "q": "acme"
+ }
+ },
+ "withinFilter": {
+ "summary": "Search inside a filtered, sorted view",
+ "value": {
+ "workspaceId": "ws_123",
+ "q": "acme",
+ "predicate": {
+ "all": [
+ {
+ "field": "status",
+ "op": "eq",
+ "value": "active"
+ }
+ ]
+ },
+ "sort": [
+ {
+ "field": "name",
+ "direction": "asc"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The matching cells.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/FindRowsEnvelope"
+ },
+ "example": {
+ "data": {
+ "matches": [
+ {
+ "ordinal": 12,
+ "rowId": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07",
+ "column": "company"
+ }
+ ],
+ "truncated": false
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/tables/jobs": {
+ "x-removed-get": {
+ "operationId": "listTableJobs",
+ "summary": "List Export Jobs",
+ "description": "Export jobs across a workspace — running ones plus recently finished ones, so a completed export stays re-downloadable. Poll this after `POST /export-async`, then fetch the file from `GET /export/download` once a job reports `ready`.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/jobs?workspaceId=YOUR_WORKSPACE_ID&type=export\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ },
+ {
+ "$ref": "#/components/parameters/JobTypeQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The workspace’s export jobs.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TableJobListEnvelope"
+ },
+ "example": {
+ "data": [
+ {
+ "jobId": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248",
+ "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14",
+ "tableName": "customers",
+ "status": "ready",
+ "rowsProcessed": 12043,
+ "format": "csv",
+ "hasResult": true,
+ "error": null
+ }
+ ],
+ "nextCursor": null
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/tables/{tableId}/import-async": {
+ "x-removed-post": {
+ "operationId": "importTableCsvAsync",
+ "summary": "Import CSV (Background)",
+ "description": "Start a background import of a file already uploaded to workspace storage — the path for files too large for the synchronous import.\n\nReturns as soon as the job is queued. Track it on the table itself — `GET /api/v2/tables/{tableId}` returns a `job` object with `status`, `rowsProcessed` and `error` while the import runs — and stop it with `POST /job/cancel`. (`GET /api/v2/tables/jobs` lists exports only: those run concurrently and are not derived onto the table.) `fileKey` must sit under this workspace’s storage prefix. The table’s lock flags are checked before the job slot is claimed, so a locked table answers 423 here rather than failing inside the worker.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/import-async\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"fileKey\":\"workspace/YOUR_WORKSPACE_ID/imports/contacts.csv\",\"fileName\":\"contacts.csv\",\"mode\":\"append\"}'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ImportAsyncBody"
+ },
+ "example": {
+ "workspaceId": "ws_123",
+ "fileKey": "workspace/ws_123/imports/contacts.csv",
+ "fileName": "contacts.csv",
+ "mode": "append"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The import was queued.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ImportAsyncEnvelope"
+ },
+ "example": {
+ "data": {
+ "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14",
+ "importId": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248"
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "423": {
+ "$ref": "#/components/responses/Locked"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/tables/{tableId}/export-async": {
+ "x-removed-post": {
+ "operationId": "exportTableAsync",
+ "summary": "Export Table (Background)",
+ "description": "Start a background export. Export jobs are read-only, so they bypass the one-write-job-per-table gate and can run alongside an import or delete.\n\nReturns as soon as the job is queued. Poll `GET /api/v2/tables/jobs`, then fetch the file from `GET /export/download` once the job reports `ready`.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/export-async\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"format\":\"csv\"}'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ExportAsyncBody"
+ },
+ "example": {
+ "workspaceId": "ws_123",
+ "format": "csv"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The export was queued.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ExportAsyncEnvelope"
+ },
+ "example": {
+ "data": {
+ "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14",
+ "jobId": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248"
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/tables/{tableId}/export/download": {
+ "x-removed-get": {
+ "operationId": "downloadTableExport",
+ "summary": "Download Export",
+ "description": "Resolve a finished export job to a short-lived presigned download URL.\n\nThe failure modes are deliberately distinct: a job that is not an export of this table is 404, one still running is 409 (retry later), and one whose file has aged out of storage is 410 (start a new export). A caller polling to completion needs to tell \"not yet\" from \"never again\".",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/export/download?workspaceId=YOUR_WORKSPACE_ID&jobId=YOUR_JOB_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ },
+ {
+ "$ref": "#/components/parameters/JobIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The presigned download URL.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ExportDownloadEnvelope"
+ },
+ "example": {
+ "data": {
+ "url": "https://storage.sim.ai/workspace/ws_123/exports/customers.csv?X-Amz-Signature=...",
+ "fileName": "customers.csv"
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "410": {
+ "$ref": "#/components/responses/Gone"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/tables/{tableId}/job/cancel": {
+ "x-removed-post": {
+ "operationId": "cancelTableJob",
+ "summary": "Cancel Job",
+ "description": "Stop an in-flight import or delete job. The worker halts at its next ownership check; work already committed (rows inserted or deleted) is left in place — there is no rollback.\n\nIdempotent: cancelling a job that already finished answers `canceled: false` rather than failing, so a client racing the worker is not an error. To stop workflow or enrichment cell runs instead, use `POST /cancel-runs`.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/job/cancel\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"jobId\":\"YOUR_JOB_ID\"}'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CancelJobBody"
+ },
+ "example": {
+ "workspaceId": "ws_123",
+ "jobId": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The cancel outcome.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CancelJobEnvelope"
+ },
+ "example": {
+ "data": {
+ "jobId": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248",
+ "canceled": true
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/tables/imports": {
+ "post": {
+ "operationId": "createTableImport",
+ "summary": "Create Table Import",
+ "description": "Create a table import. Upload sources return a signed control token plus single-PUT or multipart transfer instructions; workspace-file sources start immediately. Both use table jobs for processing state.",
+ "tags": ["Tables"],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "The table import resource.",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "423": {
+ "$ref": "#/components/responses/Locked"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/tables/imports/{importId}": {
+ "get": {
+ "operationId": "getTableImport",
+ "summary": "Get Table Import",
+ "description": "Read processing progress and terminal state from the table job using the same import id.",
+ "tags": ["Tables"],
+ "parameters": [
+ {
+ "name": "importId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The table import resource.",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "delete": {
+ "operationId": "cancelTableImport",
+ "summary": "Cancel Table Import",
+ "description": "Cancel an upload or processing import. Already committed row batches remain in the table.",
+ "tags": ["Tables"],
+ "parameters": [
+ {
+ "name": "importId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ },
+ {
+ "$ref": "#/components/parameters/OptionalUploadTokenHeader"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The canceled import resource.",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/tables/imports/{importId}/parts": {
+ "post": {
+ "operationId": "createTableImportPartUrls",
+ "summary": "Create Table Import Part URLs",
+ "description": "Issue short-lived signed PUT URLs for a bounded set of import part numbers.",
+ "tags": ["Tables"],
+ "parameters": [
+ {
+ "name": "importId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ },
+ {
+ "$ref": "#/components/parameters/UploadTokenHeader"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Signed URLs for the requested import parts.",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/tables/imports/{importId}/complete": {
+ "post": {
+ "operationId": "completeTableImportUpload",
+ "summary": "Complete Table Import Upload",
+ "description": "Verify the single PUT or assemble the multipart CSV or TSV, then start processing with the same import id.",
+ "tags": ["Tables"],
+ "parameters": [
+ {
+ "name": "importId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ },
+ {
+ "$ref": "#/components/parameters/UploadTokenHeader"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The queued import resource.",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "423": {
+ "$ref": "#/components/responses/Locked"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/tables/{tableId}/exports": {
+ "post": {
+ "operationId": "createTableExport",
+ "summary": "Create Table Export",
+ "description": "Create one export resource. The server completes small exports inline and queues larger exports without changing the API path.",
+ "tags": ["Tables"],
+ "parameters": [
+ {
+ "name": "tableId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "The completed or processing export resource.",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/tables/exports/{exportId}": {
+ "get": {
+ "operationId": "getTableExport",
+ "summary": "Get Table Export",
+ "description": "Read processing, progress, and terminal state for an export resource.",
+ "tags": ["Tables"],
+ "parameters": [
+ {
+ "name": "exportId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The table export resource.",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "delete": {
+ "operationId": "cancelTableExport",
+ "summary": "Cancel Table Export",
+ "description": "Cancel an export that is still processing.",
+ "tags": ["Tables"],
+ "parameters": [
+ {
+ "name": "exportId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The canceled export resource.",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/tables/exports/{exportId}/download": {
+ "get": {
+ "operationId": "downloadTableExport",
+ "summary": "Download Table Export",
+ "description": "Return a short-lived download URL once an export has completed.",
+ "tags": ["Tables"],
+ "parameters": [
+ {
+ "name": "exportId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "A short-lived URL for the generated export file.",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/tables/{tableId}/cancel-runs": {
+ "post": {
+ "operationId": "cancelTableRuns",
+ "summary": "Cancel Column Runs",
+ "description": "Stop in-flight and pending workflow or enrichment cell runs — the counterpart to `POST /columns/run`, and distinct from `POST /job/cancel`, which stops an import or delete.\n\n`scope: \"all\"` cancels every running and pending cell, optionally narrowed to rows matching `filter`; `scope: \"row\"` cancels one row’s cells and requires `rowId`. Cancelling clears the affected cells, so a read taken immediately after will show them empty.",
+ "tags": ["Tables"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/cancel-runs\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"scope\":\"all\"}'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/TableId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CancelRunsBody"
+ },
+ "examples": {
+ "everything": {
+ "summary": "Stop every run on the table",
+ "value": {
+ "workspaceId": "ws_123",
+ "scope": "all"
+ }
+ },
+ "oneRow": {
+ "summary": "Stop one row’s runs",
+ "value": {
+ "workspaceId": "ws_123",
+ "scope": "row",
+ "rowId": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "How many runs were stopped.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CancelRunsEnvelope"
+ },
+ "example": {
+ "data": {
+ "cancelled": 17
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/tables/folders": {
+ "get": {
+ "operationId": "listTablesFolders",
+ "summary": "List Folders",
+ "description": "List active folders for this resource. Omit `parentPath` for the full tree, or pass a canonical path (including `/`) for immediate children only.",
+ "tags": ["Tables"],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ },
+ {
+ "name": "parentPath",
+ "in": "query",
+ "required": false,
+ "description": "Canonical parent path. `/` lists root folders; omit for every folder.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "search",
+ "in": "query",
+ "required": false,
+ "description": "Name search.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 200
+ }
+ },
+ {
+ "name": "sortBy",
+ "in": "query",
+ "required": false,
+ "description": "Sort field.",
+ "schema": {
+ "type": "string",
+ "enum": ["name", "createdAt", "updatedAt"],
+ "default": "name"
+ }
+ },
+ {
+ "name": "sortOrder",
+ "in": "query",
+ "required": false,
+ "description": "Sort direction.",
+ "schema": {
+ "type": "string",
+ "enum": ["asc", "desc"],
+ "default": "asc"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Folders.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data", "nextCursor"],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/TablesFolder"
+ }
+ },
+ "nextCursor": {
+ "type": ["string", "null"]
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "423": {
+ "$ref": "#/components/responses/Locked"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "post": {
+ "operationId": "createTablesFolder",
+ "summary": "Create Folder",
+ "description": "Create exactly one folder leaf. Its parent path must already exist.",
+ "tags": ["Tables"],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["workspaceId", "path"],
+ "properties": {
+ "workspaceId": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string",
+ "description": "Canonical non-root folder path."
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Folder.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["folder"],
+ "properties": {
+ "folder": {
+ "$ref": "#/components/schemas/TablesFolder"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "423": {
+ "$ref": "#/components/responses/Locked"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "patch": {
+ "operationId": "relocateTablesFolder",
+ "summary": "Rename or Move Folder",
+ "description": "Rename, move, or rename and move a folder. Descendant paths change with the folder.",
+ "tags": ["Tables"],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["workspaceId", "path", "destinationPath"],
+ "properties": {
+ "workspaceId": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string",
+ "description": "Current canonical non-root path."
+ },
+ "destinationPath": {
+ "type": "string",
+ "description": "New canonical non-root path."
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Folder.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["folder"],
+ "properties": {
+ "folder": {
+ "$ref": "#/components/schemas/TablesFolder"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "423": {
+ "$ref": "#/components/responses/Locked"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "delete": {
+ "operationId": "deleteTablesFolder",
+ "summary": "Delete Folder",
+ "description": "Delete a folder. By default the folder must be empty. With `recursive=true`, its descendant folders and resources are deleted too.",
+ "tags": ["Tables"],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkspaceIdQuery"
+ },
+ {
+ "name": "path",
+ "in": "query",
+ "required": true,
+ "description": "Canonical non-root folder path.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "recursive",
+ "in": "query",
+ "required": false,
+ "description": "Whether to delete the subtree.",
+ "schema": {
+ "type": "boolean",
+ "default": false
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Deletion result.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["path", "deleted", "deletedItems"],
+ "properties": {
+ "path": {
+ "type": "string"
+ },
+ "deleted": {
+ "type": "boolean",
+ "const": true
+ },
+ "deletedItems": {
+ "type": "object",
+ "required": ["folders", "tables"],
+ "properties": {
+ "folders": {
+ "type": "integer"
+ },
+ "tables": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "423": {
+ "$ref": "#/components/responses/Locked"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "securitySchemes": {
+ "apiKey": {
+ "type": "apiKey",
+ "in": "header",
+ "name": "X-API-Key",
+ "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys."
+ }
+ },
+ "parameters": {
+ "TableId": {
+ "name": "tableId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14"
+ },
+ "description": "The unique identifier of the table."
+ },
+ "RowId": {
+ "name": "rowId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07"
+ },
+ "description": "The unique identifier of the row."
+ },
+ "WorkspaceIdQuery": {
+ "name": "workspaceId",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ },
+ "description": "The unique identifier of the workspace that owns the table."
+ },
+ "UploadTokenHeader": {
+ "name": "upload-token",
+ "in": "header",
+ "required": true,
+ "description": "The signed token returned for an upload-backed table import.",
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ }
+ },
+ "OptionalUploadTokenHeader": {
+ "name": "upload-token",
+ "in": "header",
+ "required": false,
+ "description": "Required when canceling before upload completion; omitted when canceling a running table job.",
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ }
+ },
+ "LimitQuery": {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "description": "Maximum number of items to return per page (1-1000, default 100).",
+ "schema": {
+ "type": "integer",
+ "default": 100,
+ "minimum": 1,
+ "maximum": 1000
+ }
+ },
+ "CursorQuery": {
+ "name": "cursor",
+ "in": "query",
+ "required": false,
+ "description": "Opaque pagination cursor. Pass the `nextCursor` from a previous response to fetch the next page. Omit for the first page.",
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ }
+ },
+ "ViewId": {
+ "name": "viewId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "example": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e"
+ },
+ "description": "The unique identifier of the saved view."
+ },
+ "GroupId": {
+ "name": "groupId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "example": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204"
+ },
+ "description": "The unique identifier of the workflow or enrichment group."
+ },
+ "JobIdQuery": {
+ "name": "jobId",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "example": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248"
+ },
+ "description": "The export job to resolve."
+ },
+ "ExportFormatQuery": {
+ "name": "format",
+ "in": "query",
+ "required": false,
+ "description": "Serialization for the exported file. Defaults to `csv`.",
+ "schema": {
+ "enum": ["csv", "json"],
+ "default": "csv"
+ }
+ },
+ "JobTypeQuery": {
+ "name": "type",
+ "in": "query",
+ "required": true,
+ "description": "Job kind to list. Only `export` is supported today; the parameter is required so widening it later cannot silently change what an existing caller receives.",
+ "schema": {
+ "enum": ["export"]
+ }
+ }
+ },
+ "headers": {
+ "RateLimitLimit": {
+ "description": "Maximum number of requests permitted in the current rate-limit window.",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ "RateLimitRemaining": {
+ "description": "Number of requests remaining in the current rate-limit window.",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ "RateLimitReset": {
+ "description": "ISO 8601 timestamp at which the current rate-limit window resets.",
+ "schema": {
+ "type": "string",
+ "format": "date-time"
+ }
+ },
+ "RetryAfter": {
+ "description": "Number of seconds to wait before retrying the request.",
+ "schema": {
+ "type": "integer"
+ }
+ }
+ },
+ "schemas": {
+ "V2Error": {
+ "type": "object",
+ "description": "Canonical v2 error envelope.",
+ "required": ["error"],
+ "properties": {
+ "error": {
+ "type": "object",
+ "required": ["code", "message"],
+ "properties": {
+ "code": {
+ "type": "string",
+ "description": "Machine-readable error code.",
+ "example": "BAD_REQUEST"
+ },
+ "message": {
+ "type": "string",
+ "description": "Human-readable error message."
+ },
+ "details": {
+ "description": "Optional structured error details, such as per-field validation issues."
+ }
+ }
+ }
+ }
+ },
+ "Column": {
+ "type": "object",
+ "description": "A column definition in a table schema.",
+ "required": ["name", "type"],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Stable server-assigned column id. May be absent on legacy columns created before id backfill.",
+ "example": "col_a1b2c3"
+ },
+ "name": {
+ "type": "string",
+ "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$",
+ "maxLength": 50,
+ "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.",
+ "example": "email"
+ },
+ "type": {
+ "type": "string",
+ "enum": ["string", "number", "currency", "boolean", "date", "json", "select"],
+ "description": "Data type of the column."
+ },
+ "required": {
+ "type": "boolean",
+ "default": false,
+ "description": "Whether the column requires a value on insert."
+ },
+ "unique": {
+ "type": "boolean",
+ "default": false,
+ "description": "Whether values in this column must be unique across all rows."
+ },
+ "workflowGroupId": {
+ "type": "string",
+ "description": "Set when the column is the output of a workflow group."
+ },
+ "options": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/SelectOption"
+ },
+ "description": "Declared options for a `select` column; absent on other types."
+ },
+ "multiple": {
+ "type": "boolean",
+ "description": "A `select` column that accepts multiple options per cell."
+ },
+ "currencyCode": {
+ "type": "string",
+ "pattern": "^[A-Za-z]{3}$",
+ "description": "ISO 4217 currency code for a `currency` column, e.g. `USD`. Normalized to uppercase. Only meaningful on a `currency` column.",
+ "example": "USD"
+ }
+ }
+ },
+ "ColumnInput": {
+ "type": "object",
+ "description": "Column definition supplied when creating a table or adding a column.",
+ "required": ["name", "type"],
+ "properties": {
+ "name": {
+ "type": "string",
+ "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$",
+ "maxLength": 50,
+ "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.",
+ "example": "email"
+ },
+ "type": {
+ "type": "string",
+ "enum": ["string", "number", "currency", "boolean", "date", "json", "select"],
+ "description": "Data type of the column."
+ },
+ "required": {
+ "type": "boolean",
+ "default": false,
+ "description": "Whether the column requires a value on insert."
+ },
+ "unique": {
+ "type": "boolean",
+ "default": false,
+ "description": "Whether values in this column must be unique across all rows."
+ },
+ "id": {
+ "type": "string",
+ "description": "Stable column id. Server-assigned — normally omit."
+ },
+ "options": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/SelectOption"
+ },
+ "description": "Declared options for a `select` column; absent on other types."
+ },
+ "multiple": {
+ "type": "boolean",
+ "description": "A `select` column that accepts multiple options per cell."
+ },
+ "currencyCode": {
+ "type": "string",
+ "pattern": "^[A-Za-z]{3}$",
+ "description": "ISO 4217 currency code for a `currency` column, e.g. `USD`. Normalized to uppercase. Only meaningful on a `currency` column.",
+ "example": "USD"
+ }
+ }
+ },
+ "Table": {
+ "type": "object",
+ "description": "A user-defined table with a typed column schema.",
+ "required": [
+ "id",
+ "name",
+ "description",
+ "schema",
+ "rowCount",
+ "maxRows",
+ "folderPath",
+ "locks",
+ "createdAt",
+ "updatedAt",
+ "job"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique table identifier.",
+ "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14"
+ },
+ "name": {
+ "type": "string",
+ "description": "Table name.",
+ "example": "contacts"
+ },
+ "description": {
+ "type": ["string", "null"],
+ "description": "Optional description of the table. Null when not set.",
+ "example": "Customer contact records"
+ },
+ "schema": {
+ "type": "object",
+ "description": "Table schema definition.",
+ "required": ["columns"],
+ "properties": {
+ "columns": {
+ "type": "array",
+ "description": "Array of column definitions for the table.",
+ "items": {
+ "$ref": "#/components/schemas/Column"
+ }
+ }
+ }
+ },
+ "rowCount": {
+ "type": "integer",
+ "description": "Current number of rows in the table."
+ },
+ "maxRows": {
+ "type": "integer",
+ "description": "Maximum rows allowed by the current billing plan."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when the table was created."
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when the table was last modified."
+ },
+ "folderPath": {
+ "type": "string",
+ "description": "Canonical containing-folder path. `/` is the workspace root."
+ },
+ "locks": {
+ "$ref": "#/components/schemas/TableLocks"
+ },
+ "job": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/TableJobState"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "In-flight background job, or null when the table is idle."
+ }
+ }
+ },
+ "RowData": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "Row cells keyed by column name. Each value is typed per its column definition.",
+ "example": {
+ "email": "jane@example.com",
+ "name": "Jane Doe",
+ "age": 30
+ }
+ },
+ "Row": {
+ "type": "object",
+ "description": "A single row in a table.",
+ "required": ["id", "data", "createdAt", "updatedAt"],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique row identifier.",
+ "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07"
+ },
+ "data": {
+ "$ref": "#/components/schemas/RowData"
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when the row was created."
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when the row was last modified."
+ }
+ }
+ },
+ "CreateTableBody": {
+ "type": "object",
+ "description": "Payload to create a new table.",
+ "required": ["workspaceId", "name", "schema"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace that will own the table."
+ },
+ "name": {
+ "type": "string",
+ "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$",
+ "maxLength": 128,
+ "description": "Table name. Starts with a letter or underscore; contains only alphanumerics and underscores.",
+ "example": "contacts"
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 500,
+ "description": "Optional description of the table."
+ },
+ "schema": {
+ "type": "object",
+ "required": ["columns"],
+ "description": "The table's column schema.",
+ "properties": {
+ "columns": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 50,
+ "description": "Column definitions. A table must have between 1 and 50 columns.",
+ "items": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/ColumnInput"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "workflowGroupId": {
+ "type": "string",
+ "description": "Advanced: binds the column to a workflow group's output."
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ },
+ "folderPath": {
+ "type": "string",
+ "description": "Canonical containing-folder path. `/` is the workspace root."
+ }
+ }
+ },
+ "AddColumnBody": {
+ "type": "object",
+ "description": "Payload to add a column to a table.",
+ "required": ["workspaceId", "column"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace that owns the table."
+ },
+ "column": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/ColumnInput"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "position": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Zero-based insert position in the column order. Appended at the end when omitted."
+ }
+ }
+ }
+ ],
+ "description": "The column definition to add."
+ }
+ }
+ },
+ "UpdateColumnBody": {
+ "type": "object",
+ "description": "Payload to update an existing column by name.",
+ "required": ["workspaceId", "columnName", "updates"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace that owns the table."
+ },
+ "columnName": {
+ "type": "string",
+ "description": "The current name of the column to update.",
+ "example": "phone"
+ },
+ "updates": {
+ "type": "object",
+ "description": "Fields to change. Provide at least one.",
+ "properties": {
+ "name": {
+ "type": "string",
+ "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$",
+ "maxLength": 50,
+ "description": "New column name.",
+ "example": "phone_number"
+ },
+ "type": {
+ "type": "string",
+ "enum": ["string", "number", "currency", "boolean", "date", "json", "select"],
+ "description": "New data type for the column."
+ },
+ "required": {
+ "type": "boolean",
+ "description": "Whether the column requires a value on insert."
+ },
+ "unique": {
+ "type": "boolean",
+ "description": "Whether values in this column must be unique across all rows."
+ },
+ "options": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/SelectOption"
+ },
+ "description": "Declared options for a `select` column; absent on other types."
+ },
+ "multiple": {
+ "type": "boolean",
+ "description": "A `select` column that accepts multiple options per cell."
+ },
+ "currencyCode": {
+ "type": "string",
+ "pattern": "^[A-Za-z]{3}$",
+ "description": "ISO 4217 currency code for a `currency` column, e.g. `USD`. Normalized to uppercase. Only meaningful on a `currency` column.",
+ "example": "USD"
+ }
+ }
+ }
+ }
+ },
+ "DeleteColumnBody": {
+ "type": "object",
+ "description": "Payload to delete a column by name.",
+ "required": ["workspaceId", "columnName"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace that owns the table."
+ },
+ "columnName": {
+ "type": "string",
+ "description": "The name of the column to delete.",
+ "example": "phone_number"
+ }
+ }
+ },
+ "CreateRowSingleBody": {
+ "type": "object",
+ "description": "Insert a single row.",
+ "required": ["workspaceId", "data"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace that owns the table."
+ },
+ "data": {
+ "$ref": "#/components/schemas/RowData"
+ },
+ "afterRowId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Insert directly after this row id. Mutually exclusive with beforeRowId."
+ },
+ "beforeRowId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Insert directly before this row id. Mutually exclusive with afterRowId."
+ }
+ }
+ },
+ "CreateRowBatchBody": {
+ "type": "object",
+ "description": "Insert multiple rows in one request.",
+ "required": ["workspaceId", "rows"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace that owns the table."
+ },
+ "rows": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 1000,
+ "description": "Rows to insert. Each entry is keyed by column name. Up to 1000 rows per request.",
+ "items": {
+ "$ref": "#/components/schemas/RowData"
+ }
+ }
+ }
+ },
+ "CreateRowsBody": {
+ "description": "Either a single-row payload or a batch payload.",
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/CreateRowSingleBody"
+ },
+ {
+ "$ref": "#/components/schemas/CreateRowBatchBody"
+ }
+ ]
+ },
+ "UpdateRowsByFilterBody": {
+ "type": "object",
+ "description": "Bulk-update rows matching a filter.",
+ "required": ["workspaceId", "filter", "data"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace that owns the table."
+ },
+ "filter": {
+ "$ref": "#/components/schemas/Predicate"
+ },
+ "data": {
+ "$ref": "#/components/schemas/RowData"
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 1000,
+ "description": "Maximum number of matching rows to update."
+ }
+ }
+ },
+ "DeleteRowsByFilterBody": {
+ "type": "object",
+ "description": "Delete rows matching a filter.",
+ "required": ["workspaceId", "filter"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace that owns the table."
+ },
+ "filter": {
+ "$ref": "#/components/schemas/Predicate"
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 1000,
+ "description": "Maximum number of matching rows to delete."
+ }
+ }
+ },
+ "DeleteRowsByIdsBody": {
+ "type": "object",
+ "description": "Delete an explicit list of rows by id.",
+ "required": ["workspaceId", "rowIds"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace that owns the table."
+ },
+ "rowIds": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 1000,
+ "description": "Row ids to delete. Up to 1000 ids per request.",
+ "items": {
+ "type": "string",
+ "minLength": 1
+ }
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 1000,
+ "description": "Maximum number of rows to delete."
+ }
+ }
+ },
+ "DeleteRowsBody": {
+ "description": "Provide exactly one of `filter` or `rowIds`.",
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/DeleteRowsByFilterBody"
+ },
+ {
+ "$ref": "#/components/schemas/DeleteRowsByIdsBody"
+ }
+ ]
+ },
+ "UpdateRowBody": {
+ "type": "object",
+ "description": "Partial update for a single row.",
+ "required": ["workspaceId", "data"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace that owns the table."
+ },
+ "data": {
+ "$ref": "#/components/schemas/RowData"
+ }
+ }
+ },
+ "UpsertRowBody": {
+ "type": "object",
+ "description": "Insert-or-update a row keyed by a unique column.",
+ "required": ["workspaceId", "data"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace that owns the table."
+ },
+ "data": {
+ "$ref": "#/components/schemas/RowData"
+ },
+ "conflictTarget": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Name of the unique column to resolve the conflict against. When omitted, the server uses the table's single unique column."
+ }
+ }
+ },
+ "TableEnvelope": {
+ "type": "object",
+ "description": "A single table wrapped in the v2 data envelope.",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["table"],
+ "properties": {
+ "table": {
+ "$ref": "#/components/schemas/Table"
+ }
+ }
+ }
+ }
+ },
+ "TableListEnvelope": {
+ "type": "object",
+ "description": "A page of tables. `nextCursor` is always null because the full bounded workspace set is returned in one page.",
+ "required": ["data", "nextCursor"],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Table"
+ }
+ },
+ "nextCursor": {
+ "type": ["string", "null"],
+ "description": "Opaque cursor for the next page, or null when there are no more pages."
+ }
+ }
+ },
+ "DeleteTableEnvelope": {
+ "type": "object",
+ "description": "Confirmation that a table was deleted.",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["id", "deleted"],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The id of the deleted table."
+ },
+ "deleted": {
+ "type": "boolean",
+ "const": true,
+ "description": "Confirms the table was deleted."
+ }
+ }
+ }
+ }
+ },
+ "ColumnsEnvelope": {
+ "type": "object",
+ "description": "The table's full column list after a column mutation.",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["columns"],
+ "properties": {
+ "columns": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Column"
+ }
+ }
+ }
+ }
+ }
+ },
+ "RowEnvelope": {
+ "type": "object",
+ "description": "A single row wrapped in the v2 data envelope.",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["row"],
+ "properties": {
+ "row": {
+ "$ref": "#/components/schemas/Row"
+ }
+ }
+ }
+ }
+ },
+ "RowListEnvelope": {
+ "type": "object",
+ "description": "A cursor-paginated page of rows.",
+ "required": ["data", "nextCursor"],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Row"
+ }
+ },
+ "nextCursor": {
+ "type": ["string", "null"],
+ "description": "Opaque cursor for the next page, or null on the final page."
+ }
+ }
+ },
+ "BatchInsertRowsEnvelope": {
+ "type": "object",
+ "description": "Result of a batch row insert.",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["rows", "insertedCount"],
+ "properties": {
+ "rows": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Row"
+ }
+ },
+ "insertedCount": {
+ "type": "integer",
+ "description": "Number of rows inserted."
+ }
+ }
+ }
+ }
+ },
+ "CreateRowsResponse": {
+ "description": "A single-row insert returns `{ data: { row } }`; a batch insert returns `{ data: { rows, insertedCount } }`.",
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/RowEnvelope"
+ },
+ {
+ "$ref": "#/components/schemas/BatchInsertRowsEnvelope"
+ }
+ ]
+ },
+ "UpdateRowsEnvelope": {
+ "type": "object",
+ "description": "Result of a bulk update-by-filter.",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["updatedCount", "updatedRowIds"],
+ "properties": {
+ "updatedCount": {
+ "type": "integer",
+ "description": "Number of rows updated."
+ },
+ "updatedRowIds": {
+ "type": "array",
+ "description": "Ids of the updated rows. Empty when nothing matched.",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "DeleteRowsEnvelope": {
+ "type": "object",
+ "description": "Result of a bulk delete. `requestedCount` and `missingRowIds` are present only for id-based deletes.",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["deletedCount", "deletedRowIds"],
+ "properties": {
+ "deletedCount": {
+ "type": "integer",
+ "description": "Number of rows deleted."
+ },
+ "deletedRowIds": {
+ "type": "array",
+ "description": "Ids of the deleted rows.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "requestedCount": {
+ "type": "integer",
+ "description": "Number of row ids requested. Present only for id-based deletes."
+ },
+ "missingRowIds": {
+ "type": "array",
+ "description": "Requested ids that did not exist. Present only for id-based deletes.",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "DeleteRowEnvelope": {
+ "type": "object",
+ "description": "Result of a single-row delete.",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["deletedCount", "deletedRowIds"],
+ "properties": {
+ "deletedCount": {
+ "type": "integer",
+ "description": "Always 1 when a row was deleted."
+ },
+ "deletedRowIds": {
+ "type": "array",
+ "description": "The id of the deleted row.",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "UpsertRowEnvelope": {
+ "type": "object",
+ "description": "Result of an upsert, including whether the row was inserted or updated.",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["row", "operation"],
+ "properties": {
+ "row": {
+ "$ref": "#/components/schemas/Row"
+ },
+ "operation": {
+ "type": "string",
+ "enum": ["insert", "update"],
+ "description": "Whether the row was inserted or updated."
+ }
+ }
+ }
+ }
+ },
+ "Predicate": {
+ "description": "A predicate tree: exactly one of `all` (every member must match) or `any` (at least one must). Members are conditions or nested groups; nesting expresses mixed AND/OR logic. Groups must be non-empty (1–100 members), trees at most 10 levels deep and 500 nodes total. Nodes are STRICT: unknown keys, or a node carrying both a group key and condition keys, are rejected rather than ignored.",
+ "oneOf": [
+ {
+ "type": "object",
+ "required": ["all"],
+ "additionalProperties": false,
+ "properties": {
+ "all": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 100,
+ "items": {
+ "$ref": "#/components/schemas/PredicateNode"
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "required": ["any"],
+ "additionalProperties": false,
+ "properties": {
+ "any": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 100,
+ "items": {
+ "$ref": "#/components/schemas/PredicateNode"
+ }
+ }
+ }
+ }
+ ]
+ },
+ "PredicateNode": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/Predicate"
+ },
+ {
+ "$ref": "#/components/schemas/Condition"
+ }
+ ]
+ },
+ "Condition": {
+ "type": "object",
+ "required": ["field", "op"],
+ "additionalProperties": false,
+ "properties": {
+ "field": {
+ "type": "string",
+ "maxLength": 128,
+ "description": "Column name, or a built-in: `id`, `createdAt`, `updatedAt` (camelCase — snake_case is treated as a user column and matches nothing)."
+ },
+ "op": {
+ "enum": [
+ "eq",
+ "ne",
+ "gt",
+ "gte",
+ "lt",
+ "lte",
+ "in",
+ "nin",
+ "contains",
+ "ncontains",
+ "startsWith",
+ "endsWith",
+ "like",
+ "ilike",
+ "nlike",
+ "nilike",
+ "isEmpty",
+ "isNotEmpty",
+ "isNull",
+ "isNotNull"
+ ],
+ "description": "`eq`/`ne`/`in`/`nin` are case-sensitive equality/membership. `contains`/`ncontains`/`startsWith`/`endsWith` are case-insensitive text matches — except on a multi-select column, where `contains`/`ncontains` mean set membership by option name. `like`/`nlike` are case-sensitive and `ilike`/`nilike` case-insensitive patterns with `*` as the only wildcard (literal `%`/`_` match themselves). `isEmpty`/`isNotEmpty` treat null and empty string as empty; `isNull`/`isNotNull` are strict null checks. The four `is*` operators take no `value`. Negated text matches retain rows where the cell is absent. `in`/`nin` require a non-empty array of at most 1000 values; other value-taking operators reject arrays. Select columns accept only equality/membership operators appropriate to their cardinality (single: eq/ne/in/nin; multi: contains/ncontains; both: the `is*` checks)."
+ },
+ "value": {
+ "description": "Operand. Omit for the `is*` operators. Ranges on `number` columns require numbers, on `date` columns ISO strings (compared as UTC, independent of any session timezone); ranges on `boolean`/`json` columns are rejected."
+ }
+ }
+ },
+ "SelectOption": {
+ "type": "object",
+ "required": ["id", "name"],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Stable option id — the value stored in cells."
+ },
+ "name": {
+ "type": "string",
+ "maxLength": 100,
+ "description": "Display name. Filters on select columns accept names (resolved case-insensitively)."
+ }
+ }
+ },
+ "TableLocks": {
+ "type": "object",
+ "description": "Per-table governance flags. Every flag is present. Changing them requires workspace admin.",
+ "required": ["schemaLocked", "insertLocked", "updateLocked", "deleteLocked"],
+ "properties": {
+ "schemaLocked": {
+ "type": "boolean",
+ "description": "Blocks column adds, edits, and deletes."
+ },
+ "insertLocked": {
+ "type": "boolean",
+ "description": "Blocks new rows."
+ },
+ "updateLocked": {
+ "type": "boolean",
+ "description": "Blocks cell writes to existing rows."
+ },
+ "deleteLocked": {
+ "type": "boolean",
+ "description": "Blocks row deletes and archiving the table."
+ }
+ }
+ },
+ "UpdateTableBody": {
+ "type": "object",
+ "description": "Rename, edit the description, and/or move a table. Every field beyond `workspaceId` is optional, but at least one must be present. Lock flags are read-only on this API and are not accepted here.",
+ "required": ["workspaceId"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace that owns the table."
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "description": "New table name."
+ },
+ "description": {
+ "type": ["string", "null"],
+ "maxLength": 500,
+ "description": "New table description, or null to clear it."
+ },
+ "folderPath": {
+ "type": "string",
+ "description": "Canonical containing-folder path. `/` is the workspace root."
+ }
+ },
+ "additionalProperties": false
+ },
+ "WorkspaceScopedBody": {
+ "type": "object",
+ "description": "Endpoints whose only input is the workspace the table must belong to.",
+ "required": ["workspaceId"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace that owns the table."
+ }
+ }
+ },
+ "SortSpec": {
+ "type": "array",
+ "maxItems": 16,
+ "description": "Ordered sort spec, highest priority first. Fields are column names.",
+ "items": {
+ "type": "object",
+ "required": ["field", "direction"],
+ "properties": {
+ "field": {
+ "type": "string"
+ },
+ "direction": {
+ "enum": ["asc", "desc"]
+ }
+ }
+ }
+ },
+ "ViewConfig": {
+ "type": "object",
+ "description": "A view’s saved preset: the row predicate and sort plus the column layout. Column references are stable column ids, so renaming a column never invalidates a view.",
+ "properties": {
+ "columnWidths": {
+ "type": "object",
+ "description": "Pixel widths keyed by column id.",
+ "additionalProperties": {
+ "type": "number",
+ "exclusiveMinimum": 0
+ }
+ },
+ "columnOrder": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Left-to-right column order, as column ids."
+ },
+ "pinnedColumns": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Column ids pinned while scrolling horizontally."
+ },
+ "hiddenColumns": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Column ids hidden by the view. A deny-list — a column added later is visible by default. Hiding is presentation only; the data is untouched and reappears intact when unhidden."
+ },
+ "filter": {
+ "$ref": "#/components/schemas/Predicate"
+ },
+ "sort": {
+ "$ref": "#/components/schemas/SortSpec"
+ }
+ }
+ },
+ "View": {
+ "type": "object",
+ "description": "A saved view: a named preset of filter, sort, and column layout over a table. Presentation only — a view narrows what a reader sees by default, it is never an access boundary, and every row it hides stays reachable by reading the table without it.",
+ "required": [
+ "id",
+ "tableId",
+ "name",
+ "config",
+ "isDefault",
+ "createdBy",
+ "createdAt",
+ "updatedAt"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique view identifier.",
+ "example": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e"
+ },
+ "tableId": {
+ "type": "string",
+ "description": "The table the view belongs to."
+ },
+ "name": {
+ "type": "string",
+ "description": "Display name."
+ },
+ "config": {
+ "$ref": "#/components/schemas/ViewConfig"
+ },
+ "isDefault": {
+ "type": "boolean",
+ "description": "Whether this view is the table’s default. At most one view per table is."
+ },
+ "createdBy": {
+ "type": ["string", "null"],
+ "description": "User who saved the view, or null when that user no longer exists."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "CreateViewBody": {
+ "type": "object",
+ "description": "Save a filter/sort/layout preset as a named view.",
+ "required": ["workspaceId", "name", "config"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace that owns the table."
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Display name for the view."
+ },
+ "config": {
+ "$ref": "#/components/schemas/ViewConfig"
+ }
+ }
+ },
+ "UpdateViewBody": {
+ "type": "object",
+ "description": "Change a saved view. At least one of `name`, `config`, `configPatch`, or `isDefault` is required; `config` and `configPatch` are mutually exclusive.",
+ "required": ["workspaceId"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace that owns the table."
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "description": "New display name."
+ },
+ "config": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/ViewConfig"
+ }
+ ],
+ "description": "Replaces the stored config wholesale. Use when dropping a removed filter must persist."
+ },
+ "configPatch": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/ViewConfig"
+ }
+ ],
+ "description": "Shallow-merged into the stored config server-side, so two overlapping partial writes cannot clobber each other from stale snapshots."
+ },
+ "isDefault": {
+ "type": "boolean",
+ "description": "Promote this view to the table’s default. Setting it demotes the table’s existing default in the same transaction."
+ }
+ }
+ },
+ "ViewEnvelope": {
+ "type": "object",
+ "description": "A single view wrapped in the v2 data envelope.",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["view"],
+ "properties": {
+ "view": {
+ "$ref": "#/components/schemas/View"
+ }
+ }
+ }
+ }
+ },
+ "ViewListEnvelope": {
+ "type": "object",
+ "description": "Saved views wrapped in the v2 cursor-list envelope.",
+ "required": ["data", "nextCursor"],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/View"
+ }
+ },
+ "nextCursor": {
+ "type": ["string", "null"],
+ "description": "Always null — a table carries a bounded set of views, so the list is a single full page."
+ }
+ }
+ },
+ "DeleteViewEnvelope": {
+ "type": "object",
+ "description": "Delete confirmation carrying the id of the removed view.",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["id"],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The view that was deleted."
+ }
+ }
+ }
+ }
+ },
+ "WorkflowGroup": {
+ "type": "object",
+ "description": "A workflow or enrichment group: a backing workflow (or registry enrichment) plus the output columns its runs populate. Authored in the workflow builder; exposed here so a caller can discover the group ids the run endpoints take.",
+ "required": ["id", "workflowId", "outputs"],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Group id — pass to the run endpoints."
+ },
+ "workflowId": {
+ "type": "string",
+ "description": "Backing workflow id for manual groups; empty string for enrichment groups."
+ },
+ "enrichmentId": {
+ "type": "string",
+ "description": "Registry enrichment id, present on enrichment groups."
+ },
+ "name": {
+ "type": "string",
+ "description": "Display name."
+ },
+ "type": {
+ "enum": ["manual", "enrichment"],
+ "description": "Provenance of the group. Defaults to manual when absent."
+ },
+ "dependencies": {
+ "type": "object",
+ "description": "Columns whose values must be present before the group is eligible to run.",
+ "properties": {
+ "columns": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "outputs": {
+ "type": "array",
+ "description": "Which produced value flows into which column.",
+ "items": {
+ "type": "object",
+ "required": ["blockId", "path", "columnName"],
+ "properties": {
+ "blockId": {
+ "type": "string",
+ "description": "Source block in the workflow. Empty on enrichment outputs."
+ },
+ "path": {
+ "type": "string",
+ "description": "Path into the block output. Empty on enrichment outputs."
+ },
+ "outputId": {
+ "type": "string",
+ "description": "Enrichment output id, on enrichment groups."
+ },
+ "columnName": {
+ "type": "string",
+ "description": "Column the value is written to."
+ }
+ }
+ }
+ },
+ "inputMappings": {
+ "type": "array",
+ "description": "Which table column supplies each workflow Start-block input.",
+ "items": {
+ "type": "object",
+ "required": ["inputName", "columnName"],
+ "properties": {
+ "inputName": {
+ "type": "string"
+ },
+ "columnName": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "deploymentMode": {
+ "enum": ["live", "deployed"],
+ "description": "Which workflow state per-cell runs execute against. Defaults to live (the editable draft)."
+ },
+ "autoRun": {
+ "type": "boolean",
+ "description": "When false the group never auto-fires; it runs only on an explicit request. Defaults to true."
+ }
+ }
+ },
+ "WorkflowGroupListEnvelope": {
+ "type": "object",
+ "description": "Workflow groups wrapped in the v2 cursor-list envelope.",
+ "required": ["data", "nextCursor"],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/WorkflowGroup"
+ }
+ },
+ "nextCursor": {
+ "type": ["string", "null"],
+ "description": "Always null — groups are bounded per table, so the list is a single full page."
+ }
+ }
+ },
+ "RunColumnBody": {
+ "type": "object",
+ "description": "Run one or more groups. Scope with `rowIds` (an explicit set) or `filter` (every matching row) — never both; omit both to run every row. `excludeRowIds` applies only to the filter scope.",
+ "required": ["workspaceId", "groupIds"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace that owns the table."
+ },
+ "groupIds": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "string",
+ "minLength": 1
+ },
+ "description": "Groups to run, from `GET /api/v2/tables/{tableId}/groups`."
+ },
+ "runMode": {
+ "enum": ["all", "incomplete"],
+ "default": "all",
+ "description": "`all` re-runs every dep-satisfied row. `incomplete` restricts to rows whose group has never run or whose last run failed or aborted."
+ },
+ "rowIds": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "string",
+ "minLength": 1
+ },
+ "description": "Run only these rows. Mutually exclusive with `filter`."
+ },
+ "filter": {
+ "$ref": "#/components/schemas/Predicate"
+ },
+ "excludeRowIds": {
+ "type": "array",
+ "maxItems": 1000,
+ "items": {
+ "type": "string",
+ "minLength": 1
+ },
+ "description": "Rows to skip within the `filter` scope."
+ },
+ "limit": {
+ "type": "object",
+ "description": "Cap the run to the first N eligible rows. Omit for an unbounded run.",
+ "required": ["type", "max"],
+ "properties": {
+ "type": {
+ "enum": ["rows"]
+ },
+ "max": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 1000000
+ }
+ }
+ }
+ }
+ },
+ "RunEnvelope": {
+ "type": "object",
+ "description": "Acknowledgement that a run was dispatched.",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["dispatchId"],
+ "properties": {
+ "dispatchId": {
+ "type": ["string", "null"],
+ "description": "Identifies the dispatch the runner walks. Null where no background runner is configured and cells execute inline."
+ }
+ }
+ }
+ }
+ },
+ "FindRowsBody": {
+ "type": "object",
+ "description": "Case-insensitive substring search across every cell, narrowed by the same predicate and sort grammar as `POST /api/v2/tables/{tableId}/query`.",
+ "required": ["workspaceId", "q"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace that owns the table."
+ },
+ "q": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Substring to search for."
+ },
+ "predicate": {
+ "$ref": "#/components/schemas/Predicate"
+ },
+ "sort": {
+ "$ref": "#/components/schemas/SortSpec"
+ }
+ }
+ },
+ "RowMatch": {
+ "type": "object",
+ "description": "One matching cell.",
+ "required": ["ordinal", "rowId", "column"],
+ "properties": {
+ "ordinal": {
+ "type": "integer",
+ "description": "The row’s 0-based index in the same predicate-filtered, sorted view that `POST /api/v2/tables/{tableId}/query` returns for these arguments — use it to page straight to the match."
+ },
+ "rowId": {
+ "type": "string",
+ "description": "The row holding the matching cell."
+ },
+ "column": {
+ "type": "string",
+ "description": "Name of the matching column."
+ }
+ }
+ },
+ "FindRowsEnvelope": {
+ "type": "object",
+ "description": "Matching cells wrapped in the v2 data envelope.",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["matches", "truncated"],
+ "properties": {
+ "matches": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/RowMatch"
+ }
+ },
+ "truncated": {
+ "type": "boolean",
+ "description": "True when the search hit the server-side cap and more cells match than were returned. Matches have no cursor — narrow the predicate instead of paging."
+ }
+ }
+ }
+ }
+ },
+ "ImportAsyncEnvelope": {
+ "type": "object",
+ "description": "Background-import kickoff acknowledgement.",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["tableId", "importId"],
+ "properties": {
+ "tableId": {
+ "type": "string"
+ },
+ "importId": {
+ "type": "string",
+ "description": "Job id — pass to `POST /job/cancel` to stop the import."
+ }
+ }
+ }
+ }
+ },
+ "ImportAsyncBody": {
+ "type": "object",
+ "description": "Starts a background import of a file already uploaded to workspace storage. The file is read by the worker, not from this request.",
+ "required": ["workspaceId", "fileKey", "fileName", "mode"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace that owns the table."
+ },
+ "fileKey": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Storage key of the uploaded file. Must sit under this workspace’s `workspace/{workspaceId}/` prefix.",
+ "example": "workspace/ws_123/imports/contacts.csv"
+ },
+ "fileName": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Original filename. Its extension selects the separator (.csv or .tsv)."
+ },
+ "mode": {
+ "enum": ["append", "replace"],
+ "description": "`append` adds rows; `replace` deletes every existing row first."
+ },
+ "mapping": {
+ "type": "object",
+ "description": "CSV header → column name, or null to skip the header.",
+ "additionalProperties": {
+ "type": ["string", "null"]
+ }
+ },
+ "createColumns": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "CSV headers to create as new columns before importing."
+ },
+ "timezone": {
+ "type": "string",
+ "description": "IANA zone used to read naive datetimes.",
+ "example": "America/New_York"
+ }
+ }
+ },
+ "ExportAsyncBody": {
+ "type": "object",
+ "description": "Starts a background export.",
+ "required": ["workspaceId"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace that owns the table."
+ },
+ "format": {
+ "enum": ["csv", "json"],
+ "default": "csv",
+ "description": "Serialization to produce."
+ }
+ }
+ },
+ "ExportAsyncEnvelope": {
+ "type": "object",
+ "description": "Background-export kickoff acknowledgement.",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["tableId", "jobId"],
+ "properties": {
+ "tableId": {
+ "type": "string"
+ },
+ "jobId": {
+ "type": "string",
+ "description": "Job id — poll `GET /api/v2/tables/jobs`, then fetch the file from `GET /export/download`."
+ }
+ }
+ }
+ }
+ },
+ "ExportDownloadEnvelope": {
+ "type": "object",
+ "description": "A short-lived presigned download URL for a finished export.",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["url", "fileName"],
+ "properties": {
+ "url": {
+ "type": "string",
+ "description": "Presigned URL. Expires shortly after issue — fetch it promptly."
+ },
+ "fileName": {
+ "type": "string",
+ "description": "Suggested filename for the download."
+ }
+ }
+ }
+ }
+ },
+ "TableJob": {
+ "type": "object",
+ "description": "One export job.",
+ "required": [
+ "jobId",
+ "tableId",
+ "tableName",
+ "status",
+ "rowsProcessed",
+ "format",
+ "hasResult",
+ "error"
+ ],
+ "properties": {
+ "jobId": {
+ "type": "string"
+ },
+ "tableId": {
+ "type": "string"
+ },
+ "tableName": {
+ "type": "string"
+ },
+ "status": {
+ "enum": ["running", "ready", "failed", "canceled"],
+ "description": "Only `ready` jobs can be downloaded."
+ },
+ "rowsProcessed": {
+ "type": "integer",
+ "description": "Rows written so far."
+ },
+ "format": {
+ "enum": ["csv", "json"]
+ },
+ "hasResult": {
+ "type": "boolean",
+ "description": "Whether a generated file is still available to download."
+ },
+ "error": {
+ "type": ["string", "null"],
+ "description": "Failure reason for a `failed` job; null otherwise."
+ }
+ }
+ },
+ "TableJobListEnvelope": {
+ "type": "object",
+ "description": "Export jobs wrapped in the v2 cursor-list envelope.",
+ "required": ["data", "nextCursor"],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/TableJob"
+ }
+ },
+ "nextCursor": {
+ "type": ["string", "null"],
+ "description": "Always null — the listing is bounded server-side to a single page."
+ }
+ }
+ },
+ "CancelJobBody": {
+ "type": "object",
+ "description": "Stops an in-flight import or delete job.",
+ "required": ["workspaceId", "jobId"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace that owns the table."
+ },
+ "jobId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The job to stop."
+ }
+ }
+ },
+ "CancelJobEnvelope": {
+ "type": "object",
+ "description": "Cancel outcome.",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["jobId", "canceled"],
+ "properties": {
+ "jobId": {
+ "type": "string"
+ },
+ "canceled": {
+ "type": "boolean",
+ "description": "False when the job had already finished. Cancelling is idempotent — a late request is not an error."
}
}
+ }
+ }
+ },
+ "CancelRunsBody": {
+ "type": "object",
+ "description": "Stops in-flight and pending cell runs. `filter` and `excludeRowIds` apply only to `scope: \"all\"`; `rowId` is required for `scope: \"row\"`.",
+ "required": ["workspaceId", "scope"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace that owns the table."
},
- "401": { "$ref": "#/components/responses/Unauthorized" },
- "403": { "$ref": "#/components/responses/Forbidden" },
- "404": { "$ref": "#/components/responses/NotFoundOrGated" },
- "413": {
- "description": "Request body exceeded the 1 MB cap.",
- "content": {
- "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } }
- }
+ "scope": {
+ "enum": ["all", "row"],
+ "description": "`all` cancels every running and pending cell; `row` cancels one row’s cells."
+ },
+ "rowId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Required when `scope` is `row`."
},
- "429": { "$ref": "#/components/responses/RateLimited" }
+ "filter": {
+ "$ref": "#/components/schemas/Predicate"
+ },
+ "excludeRowIds": {
+ "type": "array",
+ "maxItems": 1000,
+ "items": {
+ "type": "string",
+ "minLength": 1
+ },
+ "description": "Rows to leave running within the `filter` scope."
+ }
}
- }
- }
- },
- "components": {
- "securitySchemes": {
- "apiKey": { "type": "apiKey", "in": "header", "name": "X-API-Key" }
- },
- "schemas": {
- "Predicate": {
- "description": "A predicate tree: exactly one of `all` (every member must match) or `any` (at least one must). Members are conditions or nested groups; nesting expresses mixed AND/OR logic. Groups must be non-empty (1–100 members), trees at most 10 levels deep and 500 nodes total. Nodes are STRICT: unknown keys, or a node carrying both a group key and condition keys, are rejected rather than ignored.",
- "oneOf": [
- {
+ },
+ "CancelRunsEnvelope": {
+ "type": "object",
+ "description": "How many in-flight cell runs were stopped.",
+ "required": ["data"],
+ "properties": {
+ "data": {
"type": "object",
- "required": ["all"],
- "additionalProperties": false,
+ "required": ["cancelled"],
"properties": {
- "all": {
- "type": "array",
- "minItems": 1,
- "maxItems": 100,
- "items": { "$ref": "#/components/schemas/PredicateNode" }
+ "cancelled": {
+ "type": "integer"
}
}
+ }
+ }
+ },
+ "TableJobState": {
+ "type": "object",
+ "description": "The latest write job derived onto the table. Durable imports also expose their full lifecycle at `GET /api/v2/tables/imports/{importId}`. Exports are read-only resources and do not replace this field.",
+ "required": ["id", "type", "status", "rowsProcessed", "error"],
+ "properties": {
+ "id": {
+ "type": ["string", "null"],
+ "description": "Job id. For durable imports this is also the import resource id."
},
- {
+ "type": {
+ "enum": ["import", "delete", "export", "backfill", "update", null],
+ "description": "Which kind of job is running."
+ },
+ "status": {
+ "enum": ["running", "ready", "failed", "canceled"],
+ "description": "`running` is in-flight; the rest are terminal."
+ },
+ "rowsProcessed": {
+ "type": "integer",
+ "description": "Rows handled so far — progress for a running job."
+ },
+ "error": {
+ "type": ["string", "null"],
+ "description": "Failure reason for a `failed` job; null otherwise."
+ }
+ }
+ },
+ "WorkflowGroupOutputColumnInput": {
+ "type": "object",
+ "description": "A column the group's runs will populate. `workflowGroupId` is NOT accepted — the server stamps it from the group being written.",
+ "required": ["name", "type"],
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Column name. Must match one of `group.outputs[].columnName`.",
+ "example": "revenue"
+ },
+ "type": {
+ "type": "string",
+ "enum": ["string", "number", "currency", "boolean", "date", "json", "select"]
+ },
+ "required": {
+ "type": "boolean"
+ },
+ "unique": {
+ "type": "boolean"
+ }
+ }
+ },
+ "AddWorkflowGroupBody": {
+ "type": "object",
+ "description": "Bind a workflow or enrichment to the table and create the columns its runs populate, in one call.",
+ "required": ["workspaceId", "group", "outputColumns"],
+ "additionalProperties": false,
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1
+ },
+ "group": {
"type": "object",
- "required": ["any"],
- "additionalProperties": false,
+ "description": "The binding. `id` is optional and server-generated. Supply `workflowId` when `type` is `manual` (the default), or `enrichmentId` when it is `enrichment` — the mismatch is a 400.",
+ "required": ["outputs"],
"properties": {
- "any": {
+ "id": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Optional. Omit to have the server generate one."
+ },
+ "workflowId": {
+ "type": "string",
+ "description": "Required for `manual` groups."
+ },
+ "enrichmentId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Required for `enrichment` groups."
+ },
+ "name": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string",
+ "enum": ["manual", "enrichment"],
+ "default": "manual",
+ "description": "`manual` means workflow-backed — not hand-entered."
+ },
+ "dependencies": {
+ "type": "object",
+ "description": "Columns that must be populated before this group runs."
+ },
+ "outputs": {
"type": "array",
"minItems": 1,
- "maxItems": 100,
- "items": { "$ref": "#/components/schemas/PredicateNode" }
+ "description": "Where each value comes from. Workflow outputs carry `blockId`/`path`; enrichment outputs carry `outputId`.",
+ "items": {
+ "type": "object"
+ }
+ },
+ "inputMappings": {
+ "type": "array",
+ "description": "Workflow Start-block inputs fed from table columns.",
+ "items": {
+ "type": "object"
+ }
+ },
+ "deploymentMode": {
+ "type": "string",
+ "enum": ["live", "deployed"]
+ },
+ "autoRun": {
+ "type": "boolean",
+ "description": "Whether the group auto-fires from the scheduler."
}
}
+ },
+ "outputColumns": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "$ref": "#/components/schemas/WorkflowGroupOutputColumnInput"
+ }
+ },
+ "autoRun": {
+ "type": "boolean",
+ "default": false,
+ "description": "Backfill every existing row on creation. Defaults to **false** here (the first-party surface defaults true) — on an API key this fans out a metered run per row. Prefer POST /columns/run."
}
- ]
- },
- "PredicateNode": {
- "oneOf": [
- { "$ref": "#/components/schemas/Predicate" },
- { "$ref": "#/components/schemas/Condition" }
- ]
+ }
},
- "Condition": {
+ "UpdateWorkflowGroupBody": {
"type": "object",
- "required": ["field", "op"],
+ "description": "Restructure a group. Omitted fields keep their stored values.\n\n**Removing an output deletes that column and its values** — the same behavior as DELETE /columns on a bound column. There is no detach.",
+ "required": ["workspaceId", "groupId"],
"additionalProperties": false,
"properties": {
- "field": {
+ "workspaceId": {
"type": "string",
- "maxLength": 128,
- "description": "Column name, or a built-in: `id`, `createdAt`, `updatedAt` (camelCase — snake_case is treated as a user column and matches nothing)."
+ "minLength": 1
},
- "op": {
- "enum": [
- "eq",
- "ne",
- "gt",
- "gte",
- "lt",
- "lte",
- "in",
- "nin",
- "contains",
- "ncontains",
- "startsWith",
- "endsWith",
- "like",
- "ilike",
- "nlike",
- "nilike",
- "isEmpty",
- "isNotEmpty",
- "isNull",
- "isNotNull"
- ],
- "description": "`eq`/`ne`/`in`/`nin` are case-sensitive equality/membership. `contains`/`ncontains`/`startsWith`/`endsWith` are case-insensitive text matches — except on a multi-select column, where `contains`/`ncontains` mean set membership by option name. `like`/`nlike` are case-sensitive and `ilike`/`nilike` case-insensitive patterns with `*` as the only wildcard (literal `%`/`_` match themselves). `isEmpty`/`isNotEmpty` treat null and empty string as empty; `isNull`/`isNotNull` are strict null checks. The four `is*` operators take no `value`. Negated text matches retain rows where the cell is absent. `in`/`nin` require a non-empty array of at most 1000 values; other value-taking operators reject arrays. Select columns accept only equality/membership operators appropriate to their cardinality (single: eq/ne/in/nin; multi: contains/ncontains; both: the `is*` checks)."
+ "groupId": {
+ "type": "string",
+ "minLength": 1
},
- "value": {
- "description": "Operand. Omit for the `is*` operators. Ranges on `number` columns require numbers, on `date` columns ISO strings (compared as UTC, independent of any session timezone); ranges on `boolean`/`json` columns are rejected."
+ "workflowId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Re-point the group. Re-checked against the workspace."
+ },
+ "name": {
+ "type": "string"
+ },
+ "dependencies": {
+ "type": "object"
+ },
+ "outputs": {
+ "type": "array",
+ "items": {
+ "type": "object"
+ },
+ "description": "Full replacement set. Entries dropped here delete their columns."
+ },
+ "newOutputColumns": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/WorkflowGroupOutputColumnInput"
+ }
+ },
+ "mappingUpdates": {
+ "type": "array",
+ "items": {
+ "type": "object"
+ },
+ "description": "Re-point a column to a different workflow output, keeping the column."
+ },
+ "inputMappings": {
+ "type": "array",
+ "items": {
+ "type": "object"
+ }
+ },
+ "deploymentMode": {
+ "type": "string",
+ "enum": ["live", "deployed"]
+ },
+ "type": {
+ "type": "string",
+ "enum": ["manual", "enrichment"]
+ },
+ "autoRun": {
+ "type": "boolean"
}
}
},
- "TableSummary": {
+ "DeleteWorkflowGroupBody": {
"type": "object",
- "required": ["id", "name", "schema", "rowCount", "maxRows", "createdAt", "updatedAt"],
+ "description": "Remove a group and every column it fed.",
+ "required": ["workspaceId", "groupId"],
+ "additionalProperties": false,
"properties": {
- "id": { "type": "string" },
- "name": { "type": "string" },
- "description": { "type": ["string", "null"] },
- "schema": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1
+ },
+ "groupId": {
+ "type": "string",
+ "minLength": 1
+ }
+ }
+ },
+ "WorkflowGroupEnvelope": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
"type": "object",
+ "required": ["group", "columns"],
"properties": {
+ "group": {
+ "$ref": "#/components/schemas/WorkflowGroup"
+ },
"columns": {
"type": "array",
"items": {
- "type": "object",
- "required": ["name", "type"],
- "properties": {
- "id": { "type": "string" },
- "name": { "type": "string" },
- "type": { "enum": ["string", "number", "boolean", "date", "json", "select"] },
- "required": { "type": "boolean" },
- "unique": { "type": "boolean" },
- "options": {
- "type": "array",
- "description": "Declared choices on a `select` column.",
- "items": {
- "type": "object",
- "properties": { "id": { "type": "string" }, "name": { "type": "string" } }
- }
- },
- "multiple": { "type": "boolean" }
- }
+ "$ref": "#/components/schemas/Column"
}
}
}
- },
- "rowCount": { "type": "integer" },
- "maxRows": { "type": "integer" },
- "createdAt": { "type": "string", "format": "date-time" },
- "updatedAt": { "type": "string", "format": "date-time" }
+ }
}
},
- "ErrorBody": {
+ "DeleteWorkflowGroupEnvelope": {
"type": "object",
- "required": ["error"],
+ "required": ["data"],
"properties": {
- "error": {
+ "data": {
+ "type": "object",
+ "required": ["id", "deleted", "columns"],
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "deleted": {
+ "type": "boolean",
+ "enum": [true]
+ },
+ "columns": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Column"
+ }
+ }
+ }
+ }
+ }
+ },
+ "TablesFolder": {
+ "type": "object",
+ "required": ["name", "path", "parentPath", "createdAt", "updatedAt"],
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Folder name."
+ },
+ "path": {
"type": "string",
- "description": "Human-readable message naming the failing field/operator."
+ "description": "Canonical folder path. This is the public folder identifier."
},
- "code": {
+ "parentPath": {
"type": "string",
- "description": "Machine-readable code, present on domain validation failures."
+ "description": "Canonical parent path; `/` is the root."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time"
}
}
}
},
"responses": {
- "ValidationError": {
- "description": "Malformed request.",
+ "BadRequest": {
+ "description": "Invalid request. The request body, query parameters, or a JSON-encoded filter/sort failed validation. Inspect `error.details` for field-level issues.",
"content": {
- "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } }
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2Error"
+ },
+ "example": {
+ "error": {
+ "code": "BAD_REQUEST",
+ "message": "Invalid request",
+ "details": [
+ {
+ "path": "schema.columns",
+ "message": "Table must have at least one column"
+ }
+ ]
+ }
+ }
+ }
}
},
"Unauthorized": {
- "description": "Missing or invalid API key.",
+ "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.",
"content": {
- "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } }
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2Error"
+ },
+ "example": {
+ "error": {
+ "code": "UNAUTHORIZED",
+ "message": "Invalid API key"
+ }
+ }
+ }
}
},
"Forbidden": {
- "description": "The key's workspace scope does not cover this workspace, or the caller lacks read access.",
+ "description": "Access denied. The API key cannot access the target workspace, or a plan limit (such as the maximum number of tables) has been reached.",
"content": {
- "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } }
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2Error"
+ },
+ "example": {
+ "error": {
+ "code": "FORBIDDEN",
+ "message": "Access denied"
+ }
+ }
+ }
}
},
- "NotFoundOrGated": {
- "description": "Table not found — or the `tables-v2-api` feature flag is off for this caller, in which case the entire surface answers 404. The gate is evaluated after authorization, so a 404 never distinguishes rollout cohort from missing resource for callers without access.",
+ "NotFound": {
+ "description": "The requested table or row was not found. Verify the id is correct and belongs to the specified workspace.",
"content": {
- "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } }
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2Error"
+ },
+ "example": {
+ "error": {
+ "code": "NOT_FOUND",
+ "message": "Table not found"
+ }
+ }
+ }
}
},
"RateLimited": {
- "description": "Rate limit exceeded for this key.",
+ "description": "Rate limit exceeded. Wait for the duration in the Retry-After header before retrying.",
+ "headers": {
+ "Retry-After": {
+ "$ref": "#/components/headers/RetryAfter"
+ },
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2Error"
+ },
+ "example": {
+ "error": {
+ "code": "RATE_LIMITED",
+ "message": "API rate limit exceeded",
+ "details": {
+ "retryAfter": "2026-01-15T10:31:00.000Z"
+ }
+ }
+ }
+ }
+ }
+ },
+ "InternalError": {
+ "description": "An unexpected server error occurred.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2Error"
+ },
+ "example": {
+ "error": {
+ "code": "INTERNAL_ERROR",
+ "message": "Internal server error"
+ }
+ }
+ }
+ }
+ },
+ "Conflict": {
+ "description": "The request conflicts with the current state of the resource — for example a rename to a name another table in the workspace already uses.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2Error"
+ },
+ "example": {
+ "error": {
+ "code": "CONFLICT",
+ "message": "A table named \"contacts\" already exists"
+ }
+ }
+ }
+ }
+ },
+ "Locked": {
+ "description": "The table has a lock that forbids this operation. Clear the relevant lock with `PATCH /api/v2/tables/{tableId}` (workspace admin only) and retry.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2Error"
+ },
+ "example": {
+ "error": {
+ "code": "LOCKED",
+ "message": "Schema changes are locked for this table"
+ }
+ }
+ }
+ }
+ },
+ "PayloadTooLarge": {
+ "description": "The import source exceeds the 5 GB resource limit.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2Error"
+ },
+ "example": {
+ "error": {
+ "code": "PAYLOAD_TOO_LARGE",
+ "message": "CSV import file exceeds maximum size"
+ }
+ }
+ }
+ }
+ },
+ "Gone": {
+ "description": "The generated export file has aged out of storage. Start a new export rather than retrying this download.",
"content": {
- "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } }
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/V2Error"
+ },
+ "example": {
+ "error": {
+ "code": "NOT_FOUND",
+ "message": "Export file is no longer available"
+ }
+ }
+ }
}
}
}
diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json
new file mode 100644
index 00000000000..22fc0bae059
--- /dev/null
+++ b/apps/docs/openapi-v2-workflows.json
@@ -0,0 +1,2959 @@
+{
+ "openapi": "3.1.0",
+ "info": {
+ "title": "Sim API v2 — Workflows",
+ "description": "Version 2 of the Sim REST API for managing workflows (create, list, inspect, update, delete), their deployment versions, and deployments (deploy, undeploy, rollback).\n\nThe v2 surface standardizes on a single response family across every endpoint:\n- Single resource: `{ \"data\": T }`\n- List: `{ \"data\": T[], \"nextCursor\": string | null }`\n- Error: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`\n\nLists use an opaque cursor (Stripe/Slack-style): send `limit` and `cursor`, receive `{ data, nextCursor }`. Cursors are opaque tokens — pass back the `nextCursor` from the previous page verbatim and stop when it is `null`. Total counts are not returned on lists.\n\nRate-limit state is carried in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` response headers (not in the body). A throttled request returns `429` with a `Retry-After` header.\n\nAuthenticate every request with an API key in the `X-API-Key` header.",
+ "version": "2.0.0",
+ "contact": {
+ "name": "Sim Support",
+ "email": "help@sim.ai",
+ "url": "https://www.sim.ai"
+ },
+ "license": {
+ "name": "Apache 2.0",
+ "url": "https://www.apache.org/licenses/LICENSE-2.0.html"
+ }
+ },
+ "servers": [
+ {
+ "url": "https://www.sim.ai",
+ "description": "Production"
+ }
+ ],
+ "tags": [
+ {
+ "name": "Workflows",
+ "description": "Create, list, inspect, update, and delete workflows, enumerate their deployment versions, and manage deployments (deploy, undeploy, rollback) on the v2 API."
+ }
+ ],
+ "security": [
+ {
+ "apiKey": []
+ }
+ ],
+ "paths": {
+ "/api/v2/workflows": {
+ "get": {
+ "operationId": "listWorkflows",
+ "summary": "List Workflows",
+ "description": "Retrieve workflows in a workspace using opaque cursor-based pagination. Results are ordered deterministically; follow `nextCursor` to page through the full set, and stop when it is `null`.",
+ "tags": ["Workflows"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkspaceId"
+ },
+ {
+ "name": "folderPath",
+ "in": "query",
+ "required": false,
+ "description": "Filter results to only include workflows within this folder.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "deployedOnly",
+ "in": "query",
+ "required": false,
+ "description": "When true, only return workflows that are currently deployed. Useful for listing workflows available for API execution.",
+ "schema": {
+ "type": "boolean",
+ "default": false
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "description": "Maximum number of workflows to return per page. Must be between 1 and 100.",
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 50
+ }
+ },
+ {
+ "name": "cursor",
+ "in": "query",
+ "required": false,
+ "description": "Opaque pagination cursor returned from a previous request's `nextCursor` field. Omit for the first page.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "search",
+ "in": "query",
+ "required": false,
+ "description": "Case-insensitive substring match against the workflow `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 200
+ }
+ },
+ {
+ "name": "sortBy",
+ "in": "query",
+ "required": false,
+ "description": "Field to sort by. `position` is the workspace's own manual arrangement of its workflows, which is the default order. The cursor is a keyset over the active sort, so it carries the sort it was minted under. Replaying a cursor after changing `sortBy` or `sortOrder` returns `400`; restart pagination without a cursor instead.",
+ "schema": {
+ "type": "string",
+ "enum": ["position", "name", "createdAt", "updatedAt", "runCount"],
+ "default": "position"
+ }
+ },
+ {
+ "name": "sortOrder",
+ "in": "query",
+ "required": false,
+ "description": "Sort direction. The cursor is a keyset over the active sort, so it carries the sort it was minted under. Replaying a cursor after changing `sortBy` or `sortOrder` returns `400`; restart pagination without a cursor instead.",
+ "schema": {
+ "type": "string",
+ "enum": ["asc", "desc"],
+ "default": "asc"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "A page of workflows.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data", "nextCursor"],
+ "properties": {
+ "data": {
+ "type": "array",
+ "description": "Workflows for the current page.",
+ "items": {
+ "$ref": "#/components/schemas/WorkflowListItem"
+ }
+ },
+ "nextCursor": {
+ "type": "string",
+ "nullable": true,
+ "description": "Opaque cursor for fetching the next page. `null` when there are no more results."
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "post": {
+ "operationId": "createWorkflowV2",
+ "summary": "Create Workflow",
+ "description": "Create an empty workflow in a workspace. The workflow is created with a default start block and no deployment, so it must be edited and deployed before it can be executed. Names must be unique within the target folder — a collision is reported as 409 rather than silently renamed.",
+ "tags": ["Workflows"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/workflows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"Customer Support Agent\"\n }'"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateWorkflowBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "The created workflow.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/WorkflowListItem"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "409": {
+ "description": "A workflow with the same name already exists in the target folder.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "423": {
+ "$ref": "#/components/responses/Locked"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/workflows/{id}": {
+ "get": {
+ "operationId": "getWorkflow",
+ "summary": "Get Workflow",
+ "description": "Retrieve a single workflow, including its workflow-level variables and trigger input field definitions. Returns 404 when the workflow does not exist or you do not have access to it (existence is not leaked).",
+ "tags": ["Workflows"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkflowId"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The requested workflow.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/WorkflowDetail"
+ }
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "patch": {
+ "operationId": "updateWorkflowV2",
+ "summary": "Update Workflow",
+ "description": "Rename a workflow, change its description, or move it between folders. Omitted fields keep their stored values, and at least one field must be supplied. Editing the workflow's graph is not part of this endpoint — use import/export for that. Returns 404 when the workflow does not exist or you do not have write access to it (existence is not leaked).",
+ "tags": ["Workflows"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/workflows/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"Customer Support Agent v2\"\n }'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkflowId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateWorkflowBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The updated workflow.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/WorkflowListItem"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "description": "A workflow with the target name already exists in the destination folder.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "423": {
+ "$ref": "#/components/responses/Locked"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "delete": {
+ "operationId": "deleteWorkflowV2",
+ "summary": "Delete Workflow",
+ "description": "Delete a workflow. It stops being returned by list and detail endpoints while its execution logs remain attributable. The last remaining workflow in a workspace cannot be deleted (400).",
+ "tags": ["Workflows"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/workflows/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkflowId"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The workflow was deleted.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/DeleteWorkflowResult"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "423": {
+ "$ref": "#/components/responses/Locked"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/workflows/{id}/versions": {
+ "get": {
+ "operationId": "listWorkflowVersionsV2",
+ "summary": "List Workflow Versions",
+ "description": "List a workflow's deployment versions, newest first. Every successful deploy appends a version; these are the version numbers `POST /api/v2/workflows/{id}/rollback` accepts. Results are cursor-paginated — follow `nextCursor` and stop when it is `null`.",
+ "tags": ["Workflows"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows/{id}/versions\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkflowId"
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "description": "Maximum number of versions to return per page. Must be between 1 and 100.",
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 50
+ }
+ },
+ {
+ "name": "cursor",
+ "in": "query",
+ "required": false,
+ "description": "Opaque pagination cursor returned from a previous request's `nextCursor` field. Omit for the first page.",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "A page of deployment versions, newest first.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data", "nextCursor"],
+ "properties": {
+ "data": {
+ "type": "array",
+ "description": "Deployment versions for the current page.",
+ "items": {
+ "$ref": "#/components/schemas/WorkflowVersion"
+ }
+ },
+ "nextCursor": {
+ "type": "string",
+ "nullable": true,
+ "description": "Opaque cursor for fetching the next page. `null` when there are no more results."
+ }
+ }
+ },
+ "example": {
+ "data": [
+ {
+ "id": "d41a9f0c-7b25-4e18-9a3d-1c6f0b8e5d24",
+ "version": 3,
+ "name": "Adds escalation branch",
+ "description": "Routes P1 tickets straight to on-call",
+ "isActive": true,
+ "createdAt": "2026-06-12T10:30:00.000Z",
+ "deployedBy": "Ada Lovelace",
+ "latestOperationStatus": "active"
+ },
+ {
+ "id": "b70e2c81-4d93-4a17-8f52-93a1c7e0d6b8",
+ "version": 2,
+ "name": null,
+ "description": null,
+ "isActive": false,
+ "createdAt": "2026-05-02T09:04:00.000Z",
+ "deployedBy": "Ada Lovelace",
+ "latestOperationStatus": null
+ }
+ ],
+ "nextCursor": null
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/workflows/{id}/versions/{version}": {
+ "get": {
+ "operationId": "getWorkflowVersionV2",
+ "summary": "Get Workflow Version",
+ "description": "Fetch one deployment version and the workflow state it pins. Use this to inspect or diff a version before activating it with `POST /api/v2/workflows/{id}/rollback`.",
+ "tags": ["Workflows"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows/{id}/versions/{version}\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkflowId"
+ },
+ {
+ "$ref": "#/components/parameters/VersionNumber"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The requested deployment version.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/WorkflowVersionDetail"
+ }
+ }
+ },
+ "example": {
+ "data": {
+ "id": "d41a9f0c-7b25-4e18-9a3d-1c6f0b8e5d24",
+ "version": 3,
+ "name": "Adds escalation branch",
+ "description": "Routes P1 tickets straight to on-call",
+ "isActive": true,
+ "createdAt": "2026-06-12T10:30:00.000Z",
+ "state": {
+ "blocks": {},
+ "edges": [],
+ "loops": {},
+ "parallels": {}
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/workflows/{id}/deploy": {
+ "post": {
+ "operationId": "deployWorkflow",
+ "summary": "Deploy Workflow",
+ "description": "Deploy the workflow's current draft state. Creates a new deployment version, makes it live for API execution, and activates schedules and triggers. Optionally accepts a `name` and `description` for the new version; the request body may be omitted entirely. Returns 404 when the workflow does not exist or you do not have access to it, and 423 when the workflow is locked.",
+ "tags": ["Workflows"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/workflows/{id}/deploy\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"name\": \"Release 4\", \"description\": \"Fixes the agent prompt\"}'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkflowId"
+ }
+ ],
+ "requestBody": {
+ "required": false,
+ "description": "Optional metadata for the new deployment version. The request body may be omitted entirely.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "description": "Optional label for the new deployment version.",
+ "example": "Release 4"
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 50000,
+ "nullable": true,
+ "description": "Optional summary of what changed in this version.",
+ "example": "Fixes the agent prompt"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Workflow deployed successfully.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/DeployResult"
+ }
+ }
+ },
+ "example": {
+ "data": {
+ "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36",
+ "isDeployed": true,
+ "deployedAt": "2026-06-12T10:30:00.000Z",
+ "version": 4,
+ "warnings": [],
+ "activeDeployment": null,
+ "latestDeploymentAttempt": null
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "413": {
+ "$ref": "#/components/responses/PayloadTooLarge"
+ },
+ "423": {
+ "$ref": "#/components/responses/Locked"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "delete": {
+ "operationId": "undeployWorkflow",
+ "summary": "Undeploy Workflow",
+ "description": "Take the workflow offline. API execution stops and schedules, webhooks, and other deployment side effects are removed. Deployment versions are retained, so the workflow can be deployed again later. Returns 400 when the workflow is not currently deployed, 404 when the workflow does not exist or you do not have access to it, and 423 when the workflow is locked.",
+ "tags": ["Workflows"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/workflows/{id}/deploy\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkflowId"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Workflow undeployed successfully.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/UndeployResult"
+ }
+ }
+ },
+ "example": {
+ "data": {
+ "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36",
+ "isDeployed": false,
+ "deployedAt": null,
+ "warnings": [],
+ "activeDeployment": null,
+ "latestDeploymentAttempt": null
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "423": {
+ "$ref": "#/components/responses/Locked"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/workflows/{id}/rollback": {
+ "post": {
+ "operationId": "rollbackWorkflow",
+ "summary": "Rollback Workflow",
+ "description": "Roll the live deployment back to a previous deployment version. The workflow must currently be deployed. By default the version immediately preceding the currently active one is re-activated; pass `version` to target a specific deployment version instead. The workflow's draft state is not modified. Returns 400 when the workflow is not deployed or there is no version to roll back to, 404 when the workflow does not exist or you do not have access to it, and 423 when the workflow is locked.",
+ "tags": ["Workflows"],
+ "x-codeSamples": [
+ {
+ "id": "curl",
+ "label": "cURL",
+ "lang": "bash",
+ "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/workflows/{id}/rollback\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"version\": 3}'"
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkflowId"
+ }
+ ],
+ "requestBody": {
+ "required": false,
+ "description": "Optional rollback target. The request body may be omitted entirely to roll back to the version immediately preceding the active one.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "version": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 2147483647,
+ "description": "The deployment version to re-activate. Defaults to the version immediately preceding the active one.",
+ "example": 3
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Workflow rolled back successfully.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/RollbackResult"
+ }
+ }
+ },
+ "example": {
+ "data": {
+ "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36",
+ "isDeployed": true,
+ "deployedAt": "2026-06-12T10:30:00.000Z",
+ "version": 3,
+ "warnings": [],
+ "activeDeployment": null,
+ "latestDeploymentAttempt": null
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "413": {
+ "$ref": "#/components/responses/PayloadTooLarge"
+ },
+ "423": {
+ "$ref": "#/components/responses/Locked"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/workflows/{id}/export": {
+ "get": {
+ "operationId": "exportWorkflow",
+ "summary": "Export a workflow",
+ "description": "Exports a workflow as a portable JSON envelope that `POST /api/v2/workflows/import` accepts verbatim. See the payload schema for what sanitization clears.",
+ "tags": ["Workflows"],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkflowId"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The export payload.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/WorkflowExportPayload"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/workflows/import": {
+ "post": {
+ "operationId": "importWorkflow",
+ "summary": "Import a workflow",
+ "description": "Creates a new workflow in the target workspace from an export payload produced by `GET /api/v2/workflows/{id}/export`. Block, edge, loop and parallel ids are regenerated on import, so the same payload can be imported repeatedly and alongside its source workflow without collisions. Bodies over 10 MB are rejected with 413.",
+ "tags": ["Workflows"],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ImportWorkflowBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "The created workflow.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/ImportedWorkflow"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "description": "A workflow with the same name already exists and deduplication failed.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "413": {
+ "$ref": "#/components/responses/PayloadTooLarge"
+ },
+ "423": {
+ "$ref": "#/components/responses/Locked"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/workflows/{id}/execute": {
+ "post": {
+ "operationId": "executeWorkflowV2",
+ "summary": "Execute a workflow",
+ "description": "Executes a deployed workflow. Auth: `X-API-Key`, or no key at all for workflows deployed with public API access (sync/stream only). Modes are body-selected — there are no mode headers on v2: `\"async\": true` queues the run and returns a 202 receipt whose `statusUrl` is the executions resource; `\"stream\": true` returns Server-Sent Events (no `{data}` envelope on frames; `includeThinking`/`includeToolCalls` additionally require the `X-Sim-Stream-Protocol: agent-events-v1` header). Sync runs return the execution resource: a failed run is HTTP 200 with `status: \"failed\"` and the structured error (the sync timeout is `status:\"failed\"` + `error.code:\"TIMEOUT\"`). A Response block's declared payload stays inside `output` — workflow authors never control response status or headers. Optional `X-Execution-Id` request header (keyed callers only) makes the run idempotent; a reused id returns 409. Rate limiting uses the workflow execution buckets (async runs debit the larger async bucket) and 429s carry `Retry-After`; execute responses do not carry `X-RateLimit-*` headers.",
+ "tags": ["Workflows"],
+ "security": [
+ {
+ "apiKey": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkflowId"
+ }
+ ],
+ "requestBody": {
+ "required": false,
+ "description": "Bodies over 10 MB are rejected with 413. Unknown keys are rejected (strict schema).",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "input": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "Workflow input, keyed by the deployed API trigger's input fields."
+ },
+ "async": {
+ "type": "boolean",
+ "default": false,
+ "description": "Queue the run; poll the returned statusUrl. Not combinable with stream/output options; requires an API key."
+ },
+ "stream": {
+ "type": "boolean",
+ "default": false,
+ "description": "Stream block outputs as Server-Sent Events."
+ },
+ "selectedOutputs": {
+ "type": "array",
+ "maxItems": 100,
+ "items": {
+ "type": "string",
+ "minLength": 1
+ },
+ "description": "Restrict streamed outputs to specific `BlockName.path` refs."
+ },
+ "includeThinking": {
+ "type": "boolean",
+ "default": false
+ },
+ "includeToolCalls": {
+ "type": "boolean",
+ "default": false
+ },
+ "includeFileBase64": {
+ "type": "boolean"
+ },
+ "base64MaxBytes": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 10485760
+ }
+ }
+ },
+ "example": {
+ "input": {
+ "key": "value"
+ }
+ },
+ "examples": {
+ "sync": {
+ "summary": "Synchronous run",
+ "value": {
+ "input": {
+ "key": "value"
+ }
+ }
+ },
+ "async": {
+ "summary": "Queued run",
+ "value": {
+ "input": {
+ "key": "value"
+ },
+ "async": true
+ }
+ },
+ "stream": {
+ "summary": "SSE stream",
+ "value": {
+ "input": {
+ "key": "value"
+ },
+ "stream": true,
+ "selectedOutputs": ["Agent.content"]
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "The execution resource. Served with `Cache-Control: private, no-store` and the `X-Execution-Id` header.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/ExecutionResource"
+ }
+ }
+ },
+ "example": {
+ "data": {
+ "executionId": "8f14e45f-ceea-467f-a",
+ "workflowId": "wf_123",
+ "status": "completed",
+ "output": {
+ "result": "done"
+ },
+ "error": null,
+ "startedAt": "2026-07-31T00:00:00.000Z",
+ "endedAt": "2026-07-31T00:00:01.000Z",
+ "durationMs": 1000
+ }
+ },
+ "examples": {
+ "completed": {
+ "summary": "Completed run",
+ "value": {
+ "data": {
+ "executionId": "exec_1",
+ "workflowId": "wf_123",
+ "status": "completed",
+ "output": {
+ "result": "done"
+ },
+ "error": null,
+ "durationMs": 1000
+ }
+ }
+ },
+ "failed": {
+ "summary": "Failed run (still HTTP 200)",
+ "value": {
+ "data": {
+ "executionId": "exec_2",
+ "workflowId": "wf_123",
+ "status": "failed",
+ "output": {
+ "partial": true
+ },
+ "error": {
+ "message": "Invalid credentials",
+ "code": "BLOCK_EXECUTION_FAILED",
+ "blockId": "b_9",
+ "blockName": "Send Email",
+ "blockType": "gmail"
+ },
+ "durationMs": 310
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "202": {
+ "description": "Queued (async). Poll `statusUrl` until `status` is terminal.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["executionId", "statusUrl"],
+ "properties": {
+ "executionId": {
+ "type": "string"
+ },
+ "statusUrl": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "example": {
+ "data": {
+ "executionId": "exec_1",
+ "statusUrl": "https://www.sim.ai/api/v2/workflows/wf_123/executions/exec_1"
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "description": "The `X-Execution-Id` was already used.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "413": {
+ "description": "Request body exceeds the 10 MB limit."
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ },
+ "503": {
+ "description": "Execution infrastructure temporarily unavailable.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v2/workflows/{id}/executions/{executionId}": {
+ "get": {
+ "operationId": "getWorkflowExecutionV2",
+ "summary": "Get execution status",
+ "description": "The single status URL for sync and async runs. Freshly queued async runs report `queued` (backfilled from the job queue before the durable record exists), then `running`, then a terminal status. Failed runs carry the structured error. `includeOutput=true` adds the final output on completed runs; `selectedOutputs` extracts specific block outputs.",
+ "tags": ["Workflows"],
+ "security": [
+ {
+ "apiKey": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkflowId"
+ },
+ {
+ "name": "executionId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ }
+ },
+ {
+ "name": "includeOutput",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "enum": ["true", "false"]
+ }
+ },
+ {
+ "name": "selectedOutputs",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Comma-separated `blockId.path` selectors."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The execution status resource.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": [
+ "executionId",
+ "workflowId",
+ "status",
+ "trigger",
+ "startedAt",
+ "endedAt",
+ "durationMs",
+ "paused",
+ "cost",
+ "error",
+ "output",
+ "blockOutputs"
+ ],
+ "properties": {
+ "executionId": {
+ "type": "string"
+ },
+ "workflowId": {
+ "type": "string"
+ },
+ "status": {
+ "enum": [
+ "queued",
+ "pending",
+ "running",
+ "completed",
+ "failed",
+ "cancelled",
+ "paused"
+ ]
+ },
+ "trigger": {
+ "type": ["string", "null"]
+ },
+ "startedAt": {
+ "type": ["string", "null"]
+ },
+ "endedAt": {
+ "type": ["string", "null"]
+ },
+ "durationMs": {
+ "type": ["number", "null"]
+ },
+ "paused": {
+ "type": ["object", "null"],
+ "description": "Pause detail for human-in-the-loop runs."
+ },
+ "cost": {
+ "type": ["object", "null"],
+ "properties": {
+ "total": {
+ "type": "number"
+ }
+ }
+ },
+ "error": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/ExecutionError"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "output": {
+ "description": "Final output; only with `includeOutput=true` on completed runs."
+ },
+ "blockOutputs": {
+ "type": ["object", "null"]
+ }
+ }
+ }
+ }
+ },
+ "example": {
+ "data": {
+ "executionId": "exec_1",
+ "workflowId": "wf_123",
+ "status": "completed",
+ "trigger": "api",
+ "startedAt": "2026-07-31T00:00:00.000Z",
+ "endedAt": "2026-07-31T00:00:01.000Z",
+ "durationMs": 1000,
+ "paused": null,
+ "cost": {
+ "total": 0.02
+ },
+ "error": null,
+ "output": null,
+ "blockOutputs": null
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/workflows/{id}/executions/{executionId}/cancel": {
+ "post": {
+ "operationId": "cancelExecutionV2",
+ "summary": "Cancel an execution",
+ "description": "Cancels a running or paused execution. `reason` explains how the cancellation was recorded.",
+ "tags": ["Workflows"],
+ "security": [
+ {
+ "apiKey": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkflowId"
+ },
+ {
+ "name": "executionId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Cancellation outcome.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": [
+ "success",
+ "executionId",
+ "redisAvailable",
+ "durablyRecorded",
+ "locallyAborted",
+ "pausedCancelled"
+ ],
+ "properties": {
+ "success": {
+ "type": "boolean"
+ },
+ "executionId": {
+ "type": "string"
+ },
+ "redisAvailable": {
+ "type": "boolean"
+ },
+ "durablyRecorded": {
+ "type": "boolean"
+ },
+ "locallyAborted": {
+ "type": "boolean"
+ },
+ "pausedCancelled": {
+ "type": "boolean"
+ },
+ "reason": {
+ "enum": [
+ "recorded",
+ "redis_unavailable",
+ "redis_write_failed",
+ "paused_event_publish_failed",
+ "paused_database_cancel_failed"
+ ]
+ }
+ }
+ }
+ }
+ },
+ "example": {
+ "data": {
+ "success": true,
+ "executionId": "exec_1",
+ "redisAvailable": true,
+ "durablyRecorded": true,
+ "locallyAborted": false,
+ "pausedCancelled": false,
+ "reason": "recorded"
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/api/v2/workflows/folders": {
+ "get": {
+ "operationId": "listWorkflowsFolders",
+ "summary": "List Folders",
+ "description": "List active folders for this resource. Omit `parentPath` for the full tree, or pass a canonical path (including `/`) for immediate children only.",
+ "tags": ["Workflows"],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkspaceId"
+ },
+ {
+ "name": "parentPath",
+ "in": "query",
+ "required": false,
+ "description": "Canonical parent path. `/` lists root folders; omit for every folder.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "search",
+ "in": "query",
+ "required": false,
+ "description": "Name search.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 200
+ }
+ },
+ {
+ "name": "sortBy",
+ "in": "query",
+ "required": false,
+ "description": "Sort field.",
+ "schema": {
+ "type": "string",
+ "enum": ["name", "createdAt", "updatedAt"],
+ "default": "name"
+ }
+ },
+ {
+ "name": "sortOrder",
+ "in": "query",
+ "required": false,
+ "description": "Sort direction.",
+ "schema": {
+ "type": "string",
+ "enum": ["asc", "desc"],
+ "default": "asc"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Folders.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data", "nextCursor"],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/WorkflowsFolder"
+ }
+ },
+ "nextCursor": {
+ "type": ["string", "null"]
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "423": {
+ "$ref": "#/components/responses/Locked"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "post": {
+ "operationId": "createWorkflowsFolder",
+ "summary": "Create Folder",
+ "description": "Create exactly one folder leaf. Its parent path must already exist.",
+ "tags": ["Workflows"],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["workspaceId", "path"],
+ "properties": {
+ "workspaceId": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string",
+ "description": "Canonical non-root folder path."
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Folder.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["folder"],
+ "properties": {
+ "folder": {
+ "$ref": "#/components/schemas/WorkflowsFolder"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "423": {
+ "$ref": "#/components/responses/Locked"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "patch": {
+ "operationId": "relocateWorkflowsFolder",
+ "summary": "Rename or Move Folder",
+ "description": "Rename, move, or rename and move a folder. Descendant paths change with the folder.",
+ "tags": ["Workflows"],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["workspaceId", "path", "destinationPath"],
+ "properties": {
+ "workspaceId": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string",
+ "description": "Current canonical non-root path."
+ },
+ "destinationPath": {
+ "type": "string",
+ "description": "New canonical non-root path."
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Folder.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["folder"],
+ "properties": {
+ "folder": {
+ "$ref": "#/components/schemas/WorkflowsFolder"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "423": {
+ "$ref": "#/components/responses/Locked"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ },
+ "delete": {
+ "operationId": "deleteWorkflowsFolder",
+ "summary": "Delete Folder",
+ "description": "Delete a folder. By default the folder must be empty. With `recursive=true`, its descendant folders and resources are deleted too.",
+ "tags": ["Workflows"],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/WorkspaceId"
+ },
+ {
+ "name": "path",
+ "in": "query",
+ "required": true,
+ "description": "Canonical non-root folder path.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "recursive",
+ "in": "query",
+ "required": false,
+ "description": "Whether to delete the subtree.",
+ "schema": {
+ "type": "boolean",
+ "default": false
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Deletion result.",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": ["path", "deleted", "deletedItems"],
+ "properties": {
+ "path": {
+ "type": "string"
+ },
+ "deleted": {
+ "type": "boolean",
+ "const": true
+ },
+ "deletedItems": {
+ "type": "object",
+ "required": ["folders", "workflows"],
+ "properties": {
+ "folders": {
+ "type": "integer"
+ },
+ "workflows": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "423": {
+ "$ref": "#/components/responses/Locked"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimited"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "securitySchemes": {
+ "apiKey": {
+ "type": "apiKey",
+ "in": "header",
+ "name": "X-API-Key",
+ "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys."
+ }
+ },
+ "parameters": {
+ "WorkspaceId": {
+ "name": "workspaceId",
+ "in": "query",
+ "required": true,
+ "description": "The unique identifier of the workspace to list workflows from.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64"
+ }
+ },
+ "WorkflowId": {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "description": "The unique workflow identifier.",
+ "schema": {
+ "type": "string",
+ "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"
+ }
+ },
+ "VersionNumber": {
+ "name": "version",
+ "in": "path",
+ "required": true,
+ "description": "The deployment version number, as returned by the version list.",
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "example": 3
+ }
+ }
+ },
+ "headers": {
+ "RateLimitLimit": {
+ "description": "The maximum number of requests permitted in the current rate-limit window.",
+ "schema": {
+ "type": "integer",
+ "example": 60
+ }
+ },
+ "RateLimitRemaining": {
+ "description": "The number of requests remaining in the current rate-limit window.",
+ "schema": {
+ "type": "integer",
+ "example": 59
+ }
+ },
+ "RateLimitReset": {
+ "description": "ISO 8601 timestamp at which the current rate-limit window resets.",
+ "schema": {
+ "type": "string",
+ "format": "date-time",
+ "example": "2026-06-29T21:50:00.000Z"
+ }
+ }
+ },
+ "schemas": {
+ "Error": {
+ "type": "object",
+ "description": "Canonical v2 error envelope. Every non-2xx response uses this shape.",
+ "required": ["error"],
+ "properties": {
+ "error": {
+ "type": "object",
+ "required": ["code", "message"],
+ "properties": {
+ "code": {
+ "type": "string",
+ "description": "Stable, machine-readable error code.",
+ "enum": [
+ "BAD_REQUEST",
+ "UNAUTHORIZED",
+ "USAGE_LIMIT_EXCEEDED",
+ "FORBIDDEN",
+ "NOT_FOUND",
+ "CONFLICT",
+ "PAYLOAD_TOO_LARGE",
+ "UNSUPPORTED_MEDIA_TYPE",
+ "LOCKED",
+ "RATE_LIMITED",
+ "INTERNAL_ERROR"
+ ]
+ },
+ "message": {
+ "type": "string",
+ "description": "Human-readable description of what went wrong."
+ },
+ "details": {
+ "description": "Optional structured detail about the error (e.g. field-level validation issues). Shape varies by error code; absent when there is nothing to add."
+ }
+ }
+ }
+ }
+ },
+ "WorkflowListItem": {
+ "type": "object",
+ "description": "Summary representation of a workflow returned by the list endpoint.",
+ "required": [
+ "id",
+ "name",
+ "description",
+ "folderPath",
+ "workspaceId",
+ "isDeployed",
+ "deployedAt",
+ "runCount",
+ "lastRunAt",
+ "createdAt",
+ "updatedAt"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique workflow identifier.",
+ "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"
+ },
+ "name": {
+ "type": "string",
+ "description": "Human-readable workflow name.",
+ "example": "Customer Support Agent"
+ },
+ "description": {
+ "type": "string",
+ "nullable": true,
+ "description": "Optional description of what the workflow does. `null` when unset.",
+ "example": "Routes incoming support tickets and drafts responses"
+ },
+ "folderPath": {
+ "type": "string",
+ "description": "Canonical containing-folder path. `/` is the workspace root.",
+ "example": "/Engineering"
+ },
+ "workspaceId": {
+ "type": "string",
+ "description": "The workspace this workflow belongs to.",
+ "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64"
+ },
+ "isDeployed": {
+ "type": "boolean",
+ "description": "Whether the workflow is currently deployed and available for API execution.",
+ "example": true
+ },
+ "deployedAt": {
+ "type": "string",
+ "format": "date-time",
+ "nullable": true,
+ "description": "ISO 8601 timestamp of the most recent deployment. `null` when never deployed.",
+ "example": "2026-06-12T10:30:00.000Z"
+ },
+ "runCount": {
+ "type": "integer",
+ "description": "Total number of times this workflow has been executed.",
+ "example": 142
+ },
+ "lastRunAt": {
+ "type": "string",
+ "format": "date-time",
+ "nullable": true,
+ "description": "ISO 8601 timestamp of the most recent execution. `null` when never run.",
+ "example": "2026-06-20T14:15:22.000Z"
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when the workflow was created.",
+ "example": "2026-01-10T09:00:00.000Z"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when the workflow was last modified.",
+ "example": "2026-06-18T16:45:00.000Z"
+ }
+ }
+ },
+ "WorkflowInputField": {
+ "type": "object",
+ "description": "A single trigger input field extracted from the workflow's input-definition block. Use these to construct the `input` object when executing the workflow.",
+ "required": ["name", "type"],
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Field name as referenced by the workflow.",
+ "example": "ticketBody"
+ },
+ "type": {
+ "type": "string",
+ "description": "Declared field type (e.g. `string`, `number`, `boolean`, `object`).",
+ "example": "string"
+ },
+ "description": {
+ "type": "string",
+ "description": "Optional human-readable description of the field.",
+ "example": "The raw text of the incoming support ticket."
+ }
+ }
+ },
+ "WorkflowDetail": {
+ "type": "object",
+ "description": "Full workflow representation: every list field plus workflow-level variables and trigger input field definitions.",
+ "required": [
+ "id",
+ "name",
+ "description",
+ "folderPath",
+ "workspaceId",
+ "isDeployed",
+ "deployedAt",
+ "runCount",
+ "lastRunAt",
+ "variables",
+ "inputs",
+ "createdAt",
+ "updatedAt"
+ ],
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/WorkflowListItem"
+ },
+ {
+ "type": "object",
+ "required": ["variables", "inputs"],
+ "properties": {
+ "variables": {
+ "type": "object",
+ "description": "Workflow-scoped variables keyed by variable id. Each value is a structured variable object (`{ id, name, type, value, ... }`); only the inner `value` is user-defined. Empty object when the workflow defines no variables.",
+ "additionalProperties": true,
+ "example": {
+ "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60": {
+ "id": "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60",
+ "name": "supportEmail",
+ "type": "string",
+ "value": "support@example.com"
+ }
+ }
+ },
+ "inputs": {
+ "type": "array",
+ "description": "The workflow's trigger input field definitions.",
+ "items": {
+ "$ref": "#/components/schemas/WorkflowInputField"
+ }
+ }
+ }
+ }
+ ]
+ },
+ "DeploymentState": {
+ "type": "object",
+ "description": "Base deployment state shared by deploy, undeploy, and rollback results.",
+ "required": [
+ "id",
+ "isDeployed",
+ "deployedAt",
+ "warnings",
+ "activeDeployment",
+ "latestDeploymentAttempt"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique workflow identifier.",
+ "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"
+ },
+ "isDeployed": {
+ "type": "boolean",
+ "description": "Whether the workflow is deployed and available for API execution after the operation."
+ },
+ "deployedAt": {
+ "type": "string",
+ "format": "date-time",
+ "nullable": true,
+ "description": "ISO 8601 timestamp of the active deployment. `null` when the workflow is not deployed.",
+ "example": "2026-06-12T10:30:00.000Z"
+ },
+ "warnings": {
+ "type": "array",
+ "description": "Non-fatal warnings. Present when trigger, schedule, or MCP side-effect sync is still in progress or needs a redeploy. Empty array when there is nothing to report.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "activeDeployment": {
+ "type": ["object", "null"],
+ "description": "Summary of the currently live deployment version, or null when none is active."
+ },
+ "latestDeploymentAttempt": {
+ "type": ["object", "null"],
+ "description": "Lifecycle status of the most recent deploy attempt (preparing/activating/active/failed/superseded) — poll this to a terminal state; deploys admit asynchronously, so HTTP success only means the attempt was accepted."
+ }
+ }
+ },
+ "DeployResult": {
+ "description": "Deployment state returned after a successful deploy. `isDeployed` is always `true`.",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/DeploymentState"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "version": {
+ "type": "integer",
+ "description": "The deployment version that is now active. May be omitted when the version number is unavailable.",
+ "example": 4
+ }
+ }
+ }
+ ]
+ },
+ "UndeployResult": {
+ "description": "Deployment state returned after a successful undeploy. `isDeployed` is always `false`, `deployedAt` is always `null`, and no `version` is included.",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/DeploymentState"
+ }
+ ]
+ },
+ "RollbackResult": {
+ "description": "Deployment state returned after a successful rollback. `isDeployed` is always `true` and `version` identifies the re-activated deployment version.",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/DeploymentState"
+ },
+ {
+ "type": "object",
+ "required": ["version"],
+ "properties": {
+ "version": {
+ "type": "integer",
+ "description": "The deployment version that was re-activated.",
+ "example": 3
+ }
+ }
+ }
+ ]
+ },
+ "WorkflowExportPayload": {
+ "type": "object",
+ "required": ["version", "exportedAt", "workflow", "state"],
+ "description": "Portable workflow export envelope. Secret-sanitized: password fields are cleared unless the value is a whole `{{ENV_VAR}}` reference (preserved so the import resolves it in the target workspace), OAuth credentials are cleared, and workspace-scoped bindings (knowledge-base/file/channel/project selectors and id-keyed fields) are cleared rather than carried across as dangling ids — so a re-import is not a byte-for-byte clone: those bindings must be re-selected. Workflow variables are emitted as stored.",
+ "properties": {
+ "version": {
+ "const": "1.0"
+ },
+ "exportedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "workflow": {
+ "type": "object",
+ "required": ["id", "name", "description", "workspaceId", "folderPath"],
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": ["string", "null"]
+ },
+ "workspaceId": {
+ "type": ["string", "null"]
+ },
+ "folderPath": {
+ "type": "string",
+ "description": "Canonical containing-folder path. `/` is the workspace root."
+ }
+ }
+ },
+ "state": {
+ "type": "object",
+ "description": "Sanitized workflow graph: `blocks`, `edges`, `loops`, `parallels`, optional `metadata` and `variables`.",
+ "additionalProperties": true
+ }
+ }
+ },
+ "ImportWorkflowBody": {
+ "type": "object",
+ "required": ["workspaceId", "workflow"],
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1
+ },
+ "folderPath": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Canonical containing-folder path. `/` is the workspace root."
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 200,
+ "description": "Overrides the payload's own metadata name."
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 2000,
+ "description": "Overrides the payload's own metadata description."
+ },
+ "workflow": {
+ "description": "The export envelope emitted by `GET /api/v2/workflows/{id}/export` (its `data` value or the whole response body), a bare workflow state (`{ blocks, edges, ... }`), or a JSON string of either.",
+ "oneOf": [
+ {
+ "type": "string",
+ "minLength": 1
+ },
+ {
+ "type": "object",
+ "minProperties": 1
+ }
+ ]
+ }
+ }
+ },
+ "ImportedWorkflow": {
+ "type": "object",
+ "required": [
+ "id",
+ "name",
+ "description",
+ "workspaceId",
+ "folderPath",
+ "createdAt",
+ "updatedAt"
+ ],
+ "description": "The created workflow — the subset of the workflow resource knowable at import time.",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": ["string", "null"]
+ },
+ "workspaceId": {
+ "type": "string"
+ },
+ "folderPath": {
+ "type": "string",
+ "description": "Canonical containing-folder path. `/` is the workspace root."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "ExecutionError": {
+ "type": "object",
+ "required": ["message", "code"],
+ "description": "Structured execution error. Route on `code` (append-only enum) instead of matching message text. Block fields identify the failing block when attributable — with the executionId they form the reproducible handle to hand a shared workflow's provider.",
+ "properties": {
+ "message": {
+ "type": "string"
+ },
+ "code": {
+ "enum": [
+ "TIMEOUT",
+ "CANCELLED",
+ "USAGE_LIMIT_EXCEEDED",
+ "INVALID_INPUT",
+ "BLOCK_EXECUTION_FAILED",
+ "CHILD_WORKFLOW_FAILED",
+ "OUTPUT_TOO_LARGE",
+ "EXECUTION_FAILED"
+ ]
+ },
+ "blockId": {
+ "type": "string"
+ },
+ "blockName": {
+ "type": "string"
+ },
+ "blockType": {
+ "type": "string"
+ }
+ }
+ },
+ "ExecutionResource": {
+ "type": "object",
+ "required": ["executionId", "workflowId", "status", "output", "error"],
+ "description": "The execution result resource. An executionId always means 200/202 with data; only pre-execution failures use the error envelope. In-band run failures are status 'failed' with the structured error — never an HTTP error status.",
+ "properties": {
+ "executionId": {
+ "type": "string"
+ },
+ "workflowId": {
+ "type": "string"
+ },
+ "status": {
+ "enum": ["completed", "failed", "paused", "cancelled"]
+ },
+ "output": {
+ "description": "Workflow output (partial output is preserved on failures)."
+ },
+ "error": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/ExecutionError"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "startedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "endedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "durationMs": {
+ "type": "number"
+ }
+ }
+ },
+ "CreateWorkflowBody": {
+ "type": "object",
+ "description": "Request body for creating a workflow.",
+ "required": ["workspaceId", "name"],
+ "additionalProperties": false,
+ "properties": {
+ "workspaceId": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The workspace to create the workflow in. Requires write access.",
+ "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64"
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255,
+ "description": "Workflow name. Must be unique within the target folder.",
+ "example": "Customer Support Agent"
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 50000,
+ "nullable": true,
+ "description": "Optional description of what the workflow does.",
+ "example": "Routes incoming support tickets and drafts responses"
+ },
+ "folderPath": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Canonical containing-folder path. `/` is the workspace root.",
+ "example": "/Engineering"
+ }
+ }
+ },
+ "UpdateWorkflowBody": {
+ "type": "object",
+ "description": "Request body for updating a workflow's metadata. Omitted fields keep their stored values; at least one field is required.",
+ "additionalProperties": false,
+ "minProperties": 1,
+ "properties": {
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255,
+ "description": "New workflow name. Must be unique within the destination folder.",
+ "example": "Customer Support Agent v2"
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 50000,
+ "nullable": true,
+ "description": "New description. Send `null` to clear it.",
+ "example": "Routes incoming support tickets and drafts responses"
+ },
+ "folderPath": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Canonical containing-folder path. `/` is the workspace root.",
+ "example": "/Engineering"
+ }
+ }
+ },
+ "DeleteWorkflowResult": {
+ "type": "object",
+ "description": "Acknowledgement that a workflow was deleted.",
+ "required": ["id", "deleted"],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The deleted workflow's identifier.",
+ "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"
+ },
+ "deleted": {
+ "type": "boolean",
+ "enum": [true],
+ "description": "Always `true` on a successful delete."
+ }
+ }
+ },
+ "WorkflowVersion": {
+ "type": "object",
+ "description": "A deployment version of a workflow, as returned by the version list.",
+ "required": ["id", "version", "isActive", "createdAt"],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique identifier of the deployment version record.",
+ "example": "d41a9f0c-7b25-4e18-9a3d-1c6f0b8e5d24"
+ },
+ "version": {
+ "type": "integer",
+ "description": "Monotonically increasing version number. Pass this to the rollback endpoint.",
+ "example": 3
+ },
+ "name": {
+ "type": "string",
+ "nullable": true,
+ "description": "Optional label given to the version at deploy time. `null` when unset.",
+ "example": "Adds escalation branch"
+ },
+ "description": {
+ "type": "string",
+ "nullable": true,
+ "description": "Optional release note for the version. `null` when unset.",
+ "example": "Routes P1 tickets straight to on-call"
+ },
+ "isActive": {
+ "type": "boolean",
+ "description": "Whether this version is the one currently serving executions.",
+ "example": true
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when the version was created.",
+ "example": "2026-06-12T10:30:00.000Z"
+ },
+ "deployedBy": {
+ "type": "string",
+ "nullable": true,
+ "description": "Display name of the user who deployed the version. `null` when the deployer is no longer resolvable.",
+ "example": "Ada Lovelace"
+ },
+ "latestOperationStatus": {
+ "type": "string",
+ "nullable": true,
+ "enum": ["preparing", "activating", "active", "failed", "superseded"],
+ "description": "Lifecycle status of the workflow's current deploy attempt, present only on the version that attempt targets. `null` on every other version — a superseded attempt is history, not live state.",
+ "example": "active"
+ }
+ }
+ },
+ "WorkflowVersionDetail": {
+ "type": "object",
+ "description": "A deployment version together with the workflow state it pins.",
+ "required": ["id", "version", "name", "description", "isActive", "createdAt", "state"],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique identifier of the deployment version record.",
+ "example": "d41a9f0c-7b25-4e18-9a3d-1c6f0b8e5d24"
+ },
+ "version": {
+ "type": "integer",
+ "description": "Monotonically increasing version number. Pass this to the rollback endpoint.",
+ "example": 3
+ },
+ "name": {
+ "type": "string",
+ "nullable": true,
+ "description": "Optional label given to the version at deploy time. `null` when unset.",
+ "example": "Adds escalation branch"
+ },
+ "description": {
+ "type": "string",
+ "nullable": true,
+ "description": "Optional release note for the version. `null` when unset.",
+ "example": "Routes P1 tickets straight to on-call"
+ },
+ "isActive": {
+ "type": "boolean",
+ "description": "Whether this version is the one currently serving executions.",
+ "example": true
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 timestamp when the version was created.",
+ "example": "2026-06-12T10:30:00.000Z"
+ },
+ "state": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "The deployed workflow graph snapshot (blocks, edges, loops, parallels). This is the state that executes while the version is active, and the state a rollback restores."
+ }
+ }
+ },
+ "WorkflowsFolder": {
+ "type": "object",
+ "required": ["name", "path", "parentPath", "locked", "createdAt", "updatedAt"],
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Folder name."
+ },
+ "path": {
+ "type": "string",
+ "description": "Canonical folder path. This is the public folder identifier."
+ },
+ "parentPath": {
+ "type": "string",
+ "description": "Canonical parent path; `/` is the root."
+ },
+ "locked": {
+ "type": "boolean",
+ "description": "Whether this workflow folder is locked."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ }
+ },
+ "responses": {
+ "BadRequest": {
+ "description": "The request was malformed or failed validation. Inspect `error.details` for field-level issues. Also returned when an operation is not allowed in the current state (e.g. undeploying a workflow that is not deployed).",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "BAD_REQUEST",
+ "message": "workspaceId is required",
+ "details": [
+ {
+ "path": ["workspaceId"],
+ "message": "workspaceId is required"
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "Unauthorized": {
+ "description": "Invalid or missing API key. Ensure the `X-API-Key` header is set with a valid key.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "UNAUTHORIZED",
+ "message": "Invalid API key"
+ }
+ }
+ }
+ }
+ },
+ "Forbidden": {
+ "description": "Access denied. You do not have permission to access the requested workspace.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "FORBIDDEN",
+ "message": "Access denied"
+ }
+ }
+ }
+ }
+ },
+ "NotFound": {
+ "description": "The workflow does not exist or you do not have access to it. Existence is not leaked, so an access failure is reported as 404.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "NOT_FOUND",
+ "message": "Workflow not found"
+ }
+ }
+ }
+ }
+ },
+ "PayloadTooLarge": {
+ "description": "The request body exceeds the maximum allowed size.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "PAYLOAD_TOO_LARGE",
+ "message": "Request body is too large"
+ }
+ }
+ }
+ }
+ },
+ "Locked": {
+ "description": "The workflow is locked and cannot be modified. Wait for the in-progress operation to finish, then retry.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "LOCKED",
+ "message": "Workflow is locked and cannot be modified"
+ }
+ }
+ }
+ }
+ },
+ "RateLimited": {
+ "description": "Rate limit exceeded. Wait for the duration specified in the `Retry-After` header before retrying.",
+ "headers": {
+ "Retry-After": {
+ "description": "Number of seconds to wait before retrying the request.",
+ "schema": {
+ "type": "integer",
+ "example": 30
+ }
+ },
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/RateLimitLimit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/RateLimitRemaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/RateLimitReset"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "RATE_LIMITED",
+ "message": "API rate limit exceeded",
+ "details": {
+ "retryAfter": "2026-06-29T21:50:00.000Z"
+ }
+ }
+ }
+ }
+ }
+ },
+ "InternalError": {
+ "description": "An unexpected error occurred while processing the request.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "code": "INTERNAL_ERROR",
+ "message": "Internal server error"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts
index 87a16bf9a46..2a252735781 100644
--- a/apps/sim/app/api/credentials/[id]/route.ts
+++ b/apps/sim/app/api/credentials/[id]/route.ts
@@ -5,7 +5,11 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { type CredentialActorContext, getCredentialActorContext } from '@/lib/credentials/access'
-import { performDeleteCredential, performUpdateCredential } from '@/lib/credentials/orchestration'
+import {
+ isProviderOutageCode,
+ performDeleteCredential,
+ performUpdateCredential,
+} from '@/lib/credentials/orchestration'
const logger = createLogger('CredentialByIdAPI')
@@ -102,7 +106,9 @@ export const PUT = withRouteHandler(
? 409
: // A provider outage during reconnect is infra, not a bad
// request — mirror the create route and runtime token route.
- result.providerErrorCode === 'provider_unavailable'
+ // Every provider family names its own outage code, so this
+ // asks the shared predicate rather than matching one literal.
+ isProviderOutageCode(result.providerErrorCode)
? 502
: result.errorCode === 'validation'
? 400
diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts
index b8484be2dac..c8b1a7c540f 100644
--- a/apps/sim/app/api/credentials/route.ts
+++ b/apps/sim/app/api/credentials/route.ts
@@ -1,149 +1,26 @@
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
-import { account, credential, credentialMember } from '@sim/db/schema'
+import { credential } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { getPostgresErrorCode } from '@sim/utils/errors'
-import { generateId } from '@sim/utils/id'
-import { and, eq, inArray, isNotNull, or } from 'drizzle-orm'
+import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
createWorkspaceCredentialContract,
credentialsListGetQuerySchema,
- normalizeCredentialEnvKey,
} from '@/lib/api/contracts/credentials'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
-import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import {
- getCredentialActorContext,
- isSharedCredentialType,
- SHARED_CREDENTIAL_TYPES,
-} from '@/lib/credentials/access'
-import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account'
-import { getCredentialCreationWorkspaceContext } from '@/lib/credentials/environment'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import {
- ServiceAccountSecretError,
- verifyAndBuildServiceAccountSecret,
-} from '@/lib/credentials/service-account-secret'
-import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors'
-import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
-import { getServiceConfigByProviderId } from '@/lib/oauth'
-import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
-import { captureServerEvent } from '@/lib/posthog/server'
+ performCreateCredential,
+ statusForCredentialOrchestrationError,
+} from '@/lib/credentials/orchestration/credential-create'
+import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('CredentialsAPI')
-/**
- * Thrown by the inner duplicate guard inside the create transaction when a
- * concurrent request slipped a row in between the outer existence check and
- * our INSERT. The catch maps this to a 409 with a typed `code` so the UI can
- * map to a friendly message.
- */
-class DuplicateCredentialError extends Error {
- constructor() {
- super('duplicate_display_name')
- this.name = 'DuplicateCredentialError'
- }
-}
-
-interface ExistingCredentialSourceParams {
- workspaceId: string
- type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account'
- accountId?: string | null
- envKey?: string | null
- envOwnerUserId?: string | null
- displayName?: string | null
- providerId?: string | null
-}
-
-type DbOrTx = typeof db | Parameters[0]>[0]
-
-async function findExistingCredentialBySourceWith(
- exec: DbOrTx,
- params: ExistingCredentialSourceParams
-) {
- const { workspaceId, type, accountId, envKey, envOwnerUserId, displayName, providerId } = params
-
- if (type === 'oauth' && accountId) {
- const [row] = await exec
- .select()
- .from(credential)
- .where(
- and(
- eq(credential.workspaceId, workspaceId),
- eq(credential.type, 'oauth'),
- eq(credential.accountId, accountId)
- )
- )
- .limit(1)
- return row ?? null
- }
-
- if (type === 'env_workspace' && envKey) {
- const [row] = await exec
- .select()
- .from(credential)
- .where(
- and(
- eq(credential.workspaceId, workspaceId),
- eq(credential.type, 'env_workspace'),
- eq(credential.envKey, envKey)
- )
- )
- .limit(1)
- return row ?? null
- }
-
- if (type === 'env_personal' && envKey && envOwnerUserId) {
- const [row] = await exec
- .select()
- .from(credential)
- .where(
- and(
- eq(credential.workspaceId, workspaceId),
- eq(credential.type, 'env_personal'),
- eq(credential.envKey, envKey),
- eq(credential.envOwnerUserId, envOwnerUserId)
- )
- )
- .limit(1)
- return row ?? null
- }
-
- if (type === 'service_account' && displayName && providerId) {
- const [row] = await exec
- .select()
- .from(credential)
- .where(
- and(
- eq(credential.workspaceId, workspaceId),
- eq(credential.type, 'service_account'),
- eq(credential.providerId, providerId),
- eq(credential.displayName, displayName)
- )
- )
- .limit(1)
- return row ?? null
- }
-
- return null
-}
-
-async function findExistingCredentialBySource(params: ExistingCredentialSourceParams) {
- return findExistingCredentialBySourceWith(db, params)
-}
-
-async function findExistingCredentialBySourceTx(
- tx: Parameters[0]>[0],
- params: ExistingCredentialSourceParams
-) {
- return findExistingCredentialBySourceWith(tx, params)
-}
-
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
const session = await getSession()
@@ -222,59 +99,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
await syncWorkspaceOAuthCredentialsForUser({ workspaceId, userId: session.user.id })
}
- const whereClauses = [eq(credential.workspaceId, workspaceId)]
-
- if (type) {
- whereClauses.push(eq(credential.type, type))
- }
- if (providerId) {
- whereClauses.push(eq(credential.providerId, providerId))
- }
-
- const isWorkspaceAdmin = workspaceAccess.canAdmin
- const accessClause = isWorkspaceAdmin
- ? or(
- isNotNull(credentialMember.id),
- inArray(credential.type, SHARED_CREDENTIAL_TYPES),
- eq(credential.envOwnerUserId, session.user.id)
- )
- : or(isNotNull(credentialMember.id), eq(credential.envOwnerUserId, session.user.id))
-
- const rows = await db
- .select({
- id: credential.id,
- workspaceId: credential.workspaceId,
- type: credential.type,
- displayName: credential.displayName,
- description: credential.description,
- providerId: credential.providerId,
- accountId: credential.accountId,
- envKey: credential.envKey,
- envOwnerUserId: credential.envOwnerUserId,
- createdBy: credential.createdBy,
- createdAt: credential.createdAt,
- updatedAt: credential.updatedAt,
- memberRole: credentialMember.role,
- })
- .from(credential)
- .leftJoin(
- credentialMember,
- and(
- eq(credentialMember.credentialId, credential.id),
- eq(credentialMember.userId, session.user.id),
- eq(credentialMember.status, 'active')
- )
- )
- .where(and(...whereClauses, accessClause))
-
- const credentials = rows.map(({ memberRole, ...rest }) => ({
- ...rest,
- role:
- (rest.type === 'env_personal' && rest.envOwnerUserId === session.user.id) ||
- (isWorkspaceAdmin && isSharedCredentialType(rest.type))
- ? 'admin'
- : (memberRole ?? 'member'),
- }))
+ const visible = await listVisibleWorkspaceCredentials({
+ workspaceId,
+ userId: session.user.id,
+ workspaceAccess,
+ type,
+ providerId,
+ })
+ const credentials = visible.map(({ hasServiceAccountKey: _hasKey, ...rest }) => rest)
return NextResponse.json({ credentials })
} catch (error) {
@@ -291,438 +123,44 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
- try {
- const parsed = await parseRequest(
- createWorkspaceCredentialContract,
- request,
- {},
- {
- validationErrorResponse: (error) =>
- NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }),
- }
- )
- if (!parsed.success) return parsed.response
-
- const {
- workspaceId,
- type,
- displayName,
- description,
- providerId,
- accountId,
- envKey,
- envOwnerUserId,
- serviceAccountJson,
- apiToken,
- domain,
- id: clientCredentialId,
- signingSecret,
- botToken,
- clientId,
- clientSecret,
- orgId,
- dataCenter,
- } = parsed.data.body
-
- const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
- if (!workspaceAccess.canWrite) {
- return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
- }
-
- let resolvedDisplayName = displayName?.trim() ?? ''
- const resolvedDescription = description?.trim() || null
- let resolvedProviderId: string | null = providerId ?? null
- let resolvedAccountId: string | null = accountId ?? null
- const resolvedEnvKey: string | null = envKey ? normalizeCredentialEnvKey(envKey) : null
- let resolvedEnvOwnerUserId: string | null = null
- let resolvedEncryptedServiceAccountKey: string | null = null
- const extraAuditMetadata: Record = {}
-
- if (type === 'oauth') {
- const [accountRow] = await db
- .select({
- id: account.id,
- userId: account.userId,
- providerId: account.providerId,
- accountId: account.accountId,
- })
- .from(account)
- .where(eq(account.id, accountId!))
- .limit(1)
-
- if (!accountRow) {
- return NextResponse.json({ error: 'OAuth account not found' }, { status: 404 })
- }
-
- if (accountRow.userId !== session.user.id) {
- return NextResponse.json(
- { error: 'Only account owners can create oauth credentials for an account' },
- { status: 403 }
- )
- }
-
- if (providerId !== accountRow.providerId) {
- return NextResponse.json(
- { error: 'providerId does not match the selected OAuth account' },
- { status: 400 }
- )
- }
- if (!resolvedDisplayName) {
- resolvedDisplayName =
- getServiceConfigByProviderId(accountRow.providerId)?.name || accountRow.providerId
- }
- } else if (type === 'service_account') {
- try {
- const secret = await verifyAndBuildServiceAccountSecret(providerId ?? '', {
- signingSecret,
- botToken,
- apiToken,
- domain,
- serviceAccountJson,
- clientId,
- clientSecret,
- orgId,
- dataCenter,
- })
- resolvedProviderId = secret.providerId
- resolvedAccountId = null
- resolvedEnvOwnerUserId = null
- if (!resolvedDisplayName) {
- resolvedDisplayName = secret.displayName
- }
- resolvedEncryptedServiceAccountKey = secret.encryptedServiceAccountKey
- Object.assign(extraAuditMetadata, secret.auditMetadata)
- } catch (error) {
- if (error instanceof ServiceAccountSecretError) {
- return NextResponse.json({ error: error.message }, { status: 400 })
- }
- throw error
- }
- } else if (type === 'env_personal') {
- resolvedEnvOwnerUserId = envOwnerUserId ?? session.user.id
- if (resolvedEnvOwnerUserId !== session.user.id) {
- return NextResponse.json(
- { error: 'Only the current user can create personal env credentials for themselves' },
- { status: 403 }
- )
- }
- resolvedProviderId = null
- resolvedAccountId = null
- resolvedDisplayName = resolvedEnvKey || ''
- } else {
- resolvedProviderId = null
- resolvedAccountId = null
- resolvedEnvOwnerUserId = null
- resolvedDisplayName = resolvedEnvKey || ''
- }
-
- if (!resolvedDisplayName) {
- return NextResponse.json({ error: 'Display name is required' }, { status: 400 })
- }
-
- const existingCredential = await findExistingCredentialBySource({
- workspaceId,
- type,
- accountId: resolvedAccountId,
- envKey: resolvedEnvKey,
- envOwnerUserId: resolvedEnvOwnerUserId,
- displayName: resolvedDisplayName,
- providerId: resolvedProviderId,
+ const parsed = await parseRequest(
+ createWorkspaceCredentialContract,
+ request,
+ {},
+ {
+ validationErrorResponse: (error) =>
+ NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }),
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ const result = await performCreateCredential({
+ ...parsed.data.body,
+ userId: session.user.id,
+ actorName: session.user.name,
+ actorEmail: session.user.email,
+ request,
+ })
+
+ if (!result.success) {
+ logger.warn(`[${requestId}] Credential create rejected`, {
+ errorCode: result.errorCode,
+ providerErrorCode: result.providerErrorCode,
})
-
- if (existingCredential) {
- // A retried custom-bot create with the SAME pre-generated id is an
- // idempotent replay and falls through to the normal existing-credential
- // path. Any other name collision must fail loudly: returning the existing
- // row as success would orphan the new id already embedded in the user's
- // Slack Request URL (Slack would post to a URL no credential resolves).
- if (
- resolvedProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID &&
- clientCredentialId &&
- existingCredential.id !== clientCredentialId
- ) {
- return NextResponse.json(
- {
- code: 'duplicate_display_name',
- error: `A Slack bot named "${resolvedDisplayName}" already exists in this workspace. Give this bot a different name.`,
- },
- { status: 409 }
- )
- }
-
- // Token service-account creates always carry a fresh token that must be
- // stored — falling through to the existing-credential path would return
- // the old credential as success and silently drop the submitted token.
- if (resolvedProviderId && isTokenServiceAccountProviderId(resolvedProviderId)) {
- return NextResponse.json(
- {
- code: 'duplicate_display_name',
- error: `A credential named "${resolvedDisplayName}" already exists in this workspace. Give this one a different name.`,
- },
- { status: 409 }
- )
- }
-
- const access = await getCredentialActorContext(existingCredential.id, session.user.id, {
- workspaceAccess,
- })
-
- if (!access.member && !access.isAdmin) {
- return NextResponse.json(
- { error: 'A credential with this source already exists in this workspace' },
- { status: 409 }
- )
- }
-
- const canUpdateExistingCredential = access.isAdmin
- const shouldUpdateDisplayName =
- type === 'oauth' &&
- resolvedDisplayName &&
- resolvedDisplayName !== existingCredential.displayName
- const shouldUpdateDescription =
- typeof description !== 'undefined' &&
- (existingCredential.description ?? null) !== resolvedDescription
-
- if (canUpdateExistingCredential && (shouldUpdateDisplayName || shouldUpdateDescription)) {
- await db
- .update(credential)
- .set({
- ...(shouldUpdateDisplayName ? { displayName: resolvedDisplayName } : {}),
- ...(shouldUpdateDescription ? { description: resolvedDescription } : {}),
- updatedAt: new Date(),
- })
- .where(eq(credential.id, existingCredential.id))
-
- const [updatedCredential] = await db
- .select()
- .from(credential)
- .where(eq(credential.id, existingCredential.id))
- .limit(1)
-
- return NextResponse.json(
- { credential: updatedCredential ?? existingCredential },
- { status: 200 }
- )
- }
-
- return NextResponse.json({ credential: existingCredential }, { status: 200 })
- }
-
- const now = new Date()
- // Honor a client-supplied id only for custom Slack bots — the setup modal
- // shows the ingest URL `/api/webhooks/slack/custom/{id}` before secrets exist.
- const credentialId =
- resolvedProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID && clientCredentialId
- ? clientCredentialId
- : generateId()
-
- const creationResult = await db.transaction(async (tx) => {
- /**
- * Discover the organization lock scope inside this transaction, then
- * acquire the same organization → user → membership locks as org
- * removal/transfer and re-authorize from the transaction before writing.
- *
- * If this insert wins, transfer sees the new source-owned personal
- * credential and blocks. If transfer wins, its permission/member cleanup
- * is visible to the authoritative re-read below and the insert is
- * refused.
- */
- const plannedContext = await getCredentialCreationWorkspaceContext({
- executor: tx,
- workspaceId,
- userId: session.user.id,
- })
- if (!plannedContext) {
- return { success: false as const, status: 403 as const, error: 'Write permission required' }
- }
-
- await acquireOrganizationUserMutationLocks(tx, {
- userId: session.user.id,
- organizationIds: plannedContext.organizationId ? [plannedContext.organizationId] : [],
- })
-
- const currentContext = await getCredentialCreationWorkspaceContext({
- executor: tx,
- workspaceId,
- userId: session.user.id,
- forUpdate: true,
- })
- if (!currentContext) {
- return { success: false as const, status: 403 as const, error: 'Write permission required' }
- }
- if (currentContext.organizationId !== plannedContext.organizationId) {
- return {
- success: false as const,
- status: 409 as const,
- error: 'Workspace organization changed while creating the credential. Please retry.',
- }
- }
- if (!currentContext.canWrite) {
- return { success: false as const, status: 403 as const, error: 'Write permission required' }
- }
-
- // service_account has no DB-level unique index on (workspaceId, providerId,
- // displayName), so we re-check inside the tx. OAuth/env_* are guarded by
- // partial unique indexes and fall through to the 23505 handler below.
- if (type === 'service_account') {
- const innerExisting = await findExistingCredentialBySourceTx(tx, {
- workspaceId,
- type,
- displayName: resolvedDisplayName,
- providerId: resolvedProviderId,
- })
- if (innerExisting) throw new DuplicateCredentialError()
- }
-
- await tx.insert(credential).values({
- id: credentialId,
- workspaceId,
- type,
- displayName: resolvedDisplayName,
- description: resolvedDescription,
- providerId: resolvedProviderId,
- accountId: resolvedAccountId,
- envKey: resolvedEnvKey,
- envOwnerUserId: resolvedEnvOwnerUserId,
- encryptedServiceAccountKey: resolvedEncryptedServiceAccountKey,
- createdBy: session.user.id,
- createdAt: now,
- updatedAt: now,
- })
-
- if ((type === 'env_workspace' || type === 'service_account') && currentContext.ownerId) {
- if (currentContext.memberUserIds.length > 0) {
- for (const memberUserId of currentContext.memberUserIds) {
- const isAdmin = memberUserId === session.user.id
- await tx.insert(credentialMember).values({
- id: generateId(),
- credentialId,
- userId: memberUserId,
- role: isAdmin ? 'admin' : 'member',
- status: 'active',
- joinedAt: now,
- invitedBy: session.user.id,
- createdAt: now,
- updatedAt: now,
- })
- }
- }
- } else {
- await tx.insert(credentialMember).values({
- id: generateId(),
- credentialId,
- userId: session.user.id,
- role: 'admin',
- status: 'active',
- joinedAt: now,
- invitedBy: session.user.id,
- createdAt: now,
- updatedAt: now,
- })
- }
-
- return { success: true as const }
+ const status = statusForCredentialOrchestrationError(result.errorCode, {
+ providerUnavailable: result.providerUnavailable,
})
- if (!creationResult.success) {
- return NextResponse.json({ error: creationResult.error }, { status: creationResult.status })
- }
-
- const [created] = await db
- .select()
- .from(credential)
- .where(eq(credential.id, credentialId))
- .limit(1)
-
- captureServerEvent(
- session.user.id,
- 'credential_connected',
- { credential_type: type, provider_id: resolvedProviderId ?? type, workspace_id: workspaceId },
- {
- groups: { workspace: workspaceId },
- setOnce: { first_credential_connected_at: new Date().toISOString() },
- }
+ return NextResponse.json(
+ result.providerErrorCode
+ ? { code: result.providerErrorCode, error: result.error }
+ : { error: result.error },
+ { status }
)
-
- recordAudit({
- workspaceId,
- actorId: session.user.id,
- actorName: session.user.name,
- actorEmail: session.user.email,
- action: AuditAction.CREDENTIAL_CREATED,
- resourceType: AuditResourceType.CREDENTIAL,
- resourceId: credentialId,
- resourceName: resolvedDisplayName,
- description: `Created ${type} credential "${resolvedDisplayName}"`,
- metadata: {
- // Provider metadata spreads first so this route's own keys stay
- // authoritative and can never be shadowed, matching the update path in
- // `lib/credentials/orchestration`.
- ...extraAuditMetadata,
- credentialType: type,
- providerId: resolvedProviderId,
- },
- request,
- })
-
- return NextResponse.json({ credential: created }, { status: 201 })
- } catch (error: unknown) {
- if (error instanceof AtlassianValidationError) {
- logger.warn(`[${requestId}] Atlassian credential rejected: ${error.code}`, {
- code: error.code,
- upstreamStatus: error.status,
- ...error.logDetail,
- })
- return NextResponse.json({ code: error.code, error: error.code }, { status: 400 })
- }
- if (error instanceof TokenServiceAccountValidationError) {
- logger.warn(`[${requestId}] Token service-account credential rejected: ${error.code}`, {
- code: error.code,
- upstreamStatus: error.status,
- ...error.logDetail,
- })
- // A provider outage is an infra failure, not a bad request — mirror the
- // runtime token route so monitoring sees a 502, not a 400.
- const status = error.code === 'provider_unavailable' ? 502 : 400
- return NextResponse.json({ code: error.code, error: error.code }, { status })
- }
- if (error instanceof DuplicateCredentialError) {
- return NextResponse.json(
- {
- code: 'duplicate_display_name',
- error: 'A credential with that name already exists in this workspace.',
- },
- { status: 409 }
- )
- }
- const pgCode = getPostgresErrorCode(error)
- if (pgCode === '23505') {
- return NextResponse.json(
- { error: 'A credential with this source already exists' },
- { status: 409 }
- )
- }
- if (pgCode === '23503') {
- return NextResponse.json(
- { error: 'Invalid credential reference or membership target' },
- { status: 400 }
- )
- }
- if (pgCode === '23514') {
- return NextResponse.json(
- { error: 'Credential source data failed validation checks' },
- { status: 400 }
- )
- }
- const errAsRecord =
- typeof error === 'object' && error !== null ? (error as Record) : {}
- logger.error(`[${requestId}] Credential create failure details`, {
- code: pgCode,
- detail: errAsRecord.detail,
- constraint: errAsRecord.constraint,
- table: errAsRecord.table,
- message: errAsRecord.message,
- })
- logger.error(`[${requestId}] Failed to create credential`, error)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
+
+ // An existing credential matched the source: an idempotent replay, not a create.
+ return NextResponse.json(
+ { credential: result.credential },
+ { status: result.created ? 201 : 200 }
+ )
})
diff --git a/apps/sim/app/api/cron/cleanup-tasks/route.ts b/apps/sim/app/api/cron/cleanup-tasks/route.ts
index 75b31492a19..184cd6fc637 100644
--- a/apps/sim/app/api/cron/cleanup-tasks/route.ts
+++ b/apps/sim/app/api/cron/cleanup-tasks/route.ts
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { dispatchCleanupJobs } from '@/lib/billing/cleanup-dispatcher'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { cleanupExpiredUploadSessions } from '@/lib/uploads/upload-session/service'
export const dynamic = 'force-dynamic'
@@ -13,11 +14,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const authError = verifyCronAuth(request, 'task cleanup')
if (authError) return authError
+ const uploadSessions = await cleanupExpiredUploadSessions()
const result = await dispatchCleanupJobs('cleanup-tasks')
- logger.info('Task cleanup jobs dispatched', result)
+ logger.info('Task cleanup jobs dispatched', { ...result, uploadSessions })
- return NextResponse.json({ triggered: true, ...result })
+ return NextResponse.json({ triggered: true, ...result, uploadSessions })
} catch (error) {
logger.error('Failed to dispatch task cleanup jobs:', { error })
return NextResponse.json({ error: 'Failed to dispatch task cleanup' }, { status: 500 })
diff --git a/apps/sim/app/api/files/multipart/route.test.ts b/apps/sim/app/api/files/multipart/route.test.ts
deleted file mode 100644
index a1200ec18c9..00000000000
--- a/apps/sim/app/api/files/multipart/route.test.ts
+++ /dev/null
@@ -1,315 +0,0 @@
-/**
- * @vitest-environment node
- */
-import { authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing'
-import { NextRequest } from 'next/server'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-
-const {
- mockIsUsingCloudStorage,
- mockGetStorageProvider,
- mockGetStorageConfig,
- mockCompleteS3MultipartUpload,
- mockCompleteBlobMultipartUpload,
- mockDeriveBlobBlockId,
- mockVerifyUploadToken,
- mockSignUploadToken,
-} = vi.hoisted(() => ({
- mockIsUsingCloudStorage: vi.fn(),
- mockGetStorageProvider: vi.fn(),
- mockGetStorageConfig: vi.fn(),
- mockCompleteS3MultipartUpload: vi.fn(),
- mockCompleteBlobMultipartUpload: vi.fn(),
- mockDeriveBlobBlockId: vi.fn(),
- mockVerifyUploadToken: vi.fn(),
- mockSignUploadToken: vi.fn(),
-}))
-
-vi.mock('@/lib/uploads', () => ({
- isUsingCloudStorage: mockIsUsingCloudStorage,
- getStorageProvider: mockGetStorageProvider,
- getStorageConfig: mockGetStorageConfig,
-}))
-
-vi.mock('@/lib/uploads/core/upload-token', () => ({
- signUploadToken: mockSignUploadToken,
- verifyUploadToken: mockVerifyUploadToken,
-}))
-
-vi.mock('@/lib/uploads/providers/s3/client', () => ({
- completeS3MultipartUpload: mockCompleteS3MultipartUpload,
- initiateS3MultipartUpload: mockInitiateS3MultipartUpload,
- getS3MultipartPartUrls: vi.fn(),
- abortS3MultipartUpload: vi.fn(),
-}))
-
-vi.mock('@/lib/uploads/providers/blob/client', () => ({
- completeMultipartUpload: mockCompleteBlobMultipartUpload,
- deriveBlobBlockId: mockDeriveBlobBlockId,
- initiateMultipartUpload: vi.fn(),
- getMultipartPartUrls: vi.fn(),
- abortMultipartUpload: vi.fn(),
-}))
-
-vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
-
-const { mockCheckStorageQuota, mockInitiateS3MultipartUpload, mockResolveStorageBillingContext } =
- vi.hoisted(() => ({
- mockCheckStorageQuota: vi.fn(),
- mockInitiateS3MultipartUpload: vi.fn(),
- mockResolveStorageBillingContext: vi.fn(),
- }))
-
-vi.mock('@/lib/billing/storage', () => ({
- checkStorageQuotaForBillingContext: mockCheckStorageQuota,
- resolveStorageBillingContext: mockResolveStorageBillingContext,
-}))
-
-import { POST } from '@/app/api/files/multipart/route'
-
-const STORAGE_CONTEXT = {
- workspaceId: 'ws-1',
- billedAccountUserId: 'workspace-owner',
- billingEntity: { type: 'organization' as const, id: 'workspace-org' },
- plan: 'team_25000',
- customStorageLimitGB: null,
-}
-
-const tokenPayload = {
- uploadId: 'upload-1',
- key: 'workspace/ws-1/123-abc-file.bin',
- userId: 'user-1',
- workspaceId: 'ws-1',
- context: 'workspace' as const,
-}
-
-const makeRequest = (action: string, body: unknown) =>
- new NextRequest(`http://localhost/api/files/multipart?action=${action}`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- })
-
-describe('POST /api/files/multipart action=complete', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
- permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
- mockIsUsingCloudStorage.mockReturnValue(true)
- mockGetStorageConfig.mockReturnValue({ bucket: 'b', region: 'r' })
- mockVerifyUploadToken.mockReturnValue({ valid: true, payload: tokenPayload })
- mockSignUploadToken.mockReturnValue('signed-token')
- mockCompleteS3MultipartUpload.mockResolvedValue({
- location: 'loc',
- path: '/api/files/serve/...',
- key: tokenPayload.key,
- })
- mockCompleteBlobMultipartUpload.mockResolvedValue({
- location: 'loc',
- path: '/api/files/serve/...',
- key: tokenPayload.key,
- })
- mockDeriveBlobBlockId.mockImplementation(
- (n: number) => `block-${n.toString().padStart(6, '0')}`
- )
- })
-
- it('rejects parts without partNumber', async () => {
- mockGetStorageProvider.mockReturnValue('s3')
- const res = await POST(
- makeRequest('complete', {
- uploadToken: 'tok',
- parts: [{ etag: 'abc' }],
- })
- )
- expect(res.status).toBe(400)
- expect(mockCompleteS3MultipartUpload).not.toHaveBeenCalled()
- })
-
- it('S3 path requires etag and forwards { ETag, PartNumber }', async () => {
- mockGetStorageProvider.mockReturnValue('s3')
-
- const missingEtag = await POST(
- makeRequest('complete', {
- uploadToken: 'tok',
- parts: [{ partNumber: 1 }],
- })
- )
- expect(missingEtag.status).toBe(500)
-
- mockCompleteS3MultipartUpload.mockClear()
-
- const ok = await POST(
- makeRequest('complete', {
- uploadToken: 'tok',
- parts: [
- { partNumber: 1, etag: 'aaa' },
- { partNumber: 2, etag: 'bbb' },
- ],
- })
- )
- expect(ok.status).toBe(200)
- expect(mockCompleteS3MultipartUpload).toHaveBeenCalledWith(
- tokenPayload.key,
- tokenPayload.uploadId,
- [
- { ETag: 'aaa', PartNumber: 1 },
- { ETag: 'bbb', PartNumber: 2 },
- ],
- expect.any(Object)
- )
- })
-
- it('Blob path derives blockId from partNumber and ignores etag', async () => {
- mockGetStorageProvider.mockReturnValue('blob')
- mockGetStorageConfig.mockReturnValue({
- containerName: 'c',
- accountName: 'a',
- accountKey: 'k',
- })
-
- const res = await POST(
- makeRequest('complete', {
- uploadToken: 'tok',
- parts: [{ partNumber: 1, etag: 'irrelevant' }, { partNumber: 2 }],
- })
- )
-
- expect(res.status).toBe(200)
- expect(mockDeriveBlobBlockId).toHaveBeenCalledWith(1)
- expect(mockDeriveBlobBlockId).toHaveBeenCalledWith(2)
- expect(mockCompleteBlobMultipartUpload).toHaveBeenCalledWith(
- tokenPayload.key,
- [
- { partNumber: 1, blockId: 'block-000001' },
- { partNumber: 2, blockId: 'block-000002' },
- ],
- expect.objectContaining({ containerName: 'c' })
- )
- })
-
- it('returns 403 when token is invalid', async () => {
- mockGetStorageProvider.mockReturnValue('s3')
- mockVerifyUploadToken.mockReturnValueOnce({ valid: false })
- const res = await POST(
- makeRequest('complete', {
- uploadToken: 'bad',
- parts: [{ partNumber: 1, etag: 'a' }],
- })
- )
- expect(res.status).toBe(403)
- })
-
- it('batch complete normalizes per upload', async () => {
- mockGetStorageProvider.mockReturnValue('s3')
- const res = await POST(
- makeRequest('complete', {
- uploads: [
- {
- uploadToken: 'tok-a',
- parts: [{ partNumber: 1, etag: 'aaa' }],
- },
- {
- uploadToken: 'tok-b',
- parts: [{ partNumber: 1, etag: 'bbb' }],
- },
- ],
- })
- )
- expect(res.status).toBe(200)
- expect(mockCompleteS3MultipartUpload).toHaveBeenCalledTimes(2)
- })
-})
-
-describe('POST /api/files/multipart action=initiate quota enforcement', () => {
- const makeInitiateRequest = (body: unknown) =>
- new NextRequest('http://localhost/api/files/multipart?action=initiate', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- })
-
- beforeEach(() => {
- vi.clearAllMocks()
- authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
- permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
- mockIsUsingCloudStorage.mockReturnValue(true)
- mockGetStorageProvider.mockReturnValue('s3')
- mockGetStorageConfig.mockReturnValue({ bucket: 'b', region: 'r' })
- mockSignUploadToken.mockReturnValue('signed-token')
- mockResolveStorageBillingContext.mockResolvedValue(STORAGE_CONTEXT)
- mockCheckStorageQuota.mockResolvedValue({ allowed: true })
- mockInitiateS3MultipartUpload.mockResolvedValue({ uploadId: 'up-1', key: 'k/file.bin' })
- })
-
- it('blocks upload when fileSize: 0 exceeds quota', async () => {
- mockCheckStorageQuota.mockResolvedValue({ allowed: false, error: 'Storage limit exceeded' })
-
- const res = await makeInitiateRequest({
- fileName: 'file.bin',
- contentType: 'application/octet-stream',
- fileSize: 0,
- workspaceId: 'ws-1',
- context: 'knowledge-base',
- })
-
- const response = await POST(res)
- expect(response.status).toBe(413)
- const body = await response.json()
- expect(body.error).toContain('Storage limit exceeded')
- })
-
- it('allows quota-enforced contexts that pass the quota check', async () => {
- const res = await makeInitiateRequest({
- fileName: 'doc.pdf',
- contentType: 'application/pdf',
- fileSize: 99999,
- workspaceId: 'ws-1',
- context: 'knowledge-base',
- })
-
- const response = await POST(res)
- expect(response.status).toBe(200)
- expect(mockResolveStorageBillingContext).toHaveBeenCalledWith('ws-1')
- expect(mockCheckStorageQuota).toHaveBeenCalledWith(STORAGE_CONTEXT, 99999)
- expect(mockInitiateS3MultipartUpload).toHaveBeenCalled()
- })
-
- it('keeps mothership chat uploads outside workspace storage quotas', async () => {
- mockCheckStorageQuota.mockResolvedValue({ allowed: false, error: 'Storage limit exceeded' })
-
- const res = await makeInitiateRequest({
- fileName: 'conversation.bin',
- contentType: 'application/octet-stream',
- fileSize: 99999,
- workspaceId: 'ws-1',
- context: 'mothership',
- })
-
- const response = await POST(res)
- expect(response.status).toBe(200)
- expect(mockResolveStorageBillingContext).not.toHaveBeenCalled()
- expect(mockCheckStorageQuota).not.toHaveBeenCalled()
- expect(mockInitiateS3MultipartUpload).toHaveBeenCalled()
- })
-
- it.each(['og-images', 'profile-pictures', 'workspace-logos', 'logs'])(
- 'rejects quota-exempt context %s — not allowed via the multipart endpoint',
- async (context) => {
- const res = await makeInitiateRequest({
- fileName: 'asset.png',
- contentType: 'image/png',
- fileSize: 100 * 1024 * 1024 * 1024,
- workspaceId: 'ws-1',
- context,
- })
-
- const response = await POST(res)
- expect(response.status).toBe(400)
- const body = await response.json()
- expect(body.error).toMatch(/invalid storage context/i)
- expect(mockCheckStorageQuota).not.toHaveBeenCalled()
- expect(mockInitiateS3MultipartUpload).not.toHaveBeenCalled()
- }
- )
-})
diff --git a/apps/sim/app/api/files/multipart/route.ts b/apps/sim/app/api/files/multipart/route.ts
deleted file mode 100644
index 07fbac67361..00000000000
--- a/apps/sim/app/api/files/multipart/route.ts
+++ /dev/null
@@ -1,533 +0,0 @@
-import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
-import { type NextRequest, NextResponse } from 'next/server'
-import {
- abortMultipartUploadContract,
- type CompleteMultipartBody,
- completeMultipartUploadContract,
- getMultipartPartUrlsContract,
- initiateMultipartUploadContract,
- multipartActionSchema,
-} from '@/lib/api/contracts/storage-transfer'
-import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
-import { getSession } from '@/lib/auth'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import {
- getStorageConfig,
- getStorageProvider,
- isUsingCloudStorage,
- type StorageContext,
-} from '@/lib/uploads'
-import { deleteFile } from '@/lib/uploads/core/storage-service'
-import {
- signUploadToken,
- type UploadTokenPayload,
- verifyUploadToken,
-} from '@/lib/uploads/core/upload-token'
-import { recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata'
-import { QUOTA_EXEMPT_STORAGE_CONTEXTS, type StorageConfig } from '@/lib/uploads/shared/types'
-import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
-
-const logger = createLogger('MultipartUploadAPI')
-
-/**
- * Contexts the multipart endpoint accepts. Small public assets and internal logs
- * are excluded because they have no large-file flow. Mothership remains
- * available for large chat attachments but is quota-exempt because chat uploads
- * do not count as durable workspace-file storage. Every other accepted context
- * is quota-enforced below.
- */
-const ALLOWED_UPLOAD_CONTEXTS = new Set([
- 'knowledge-base',
- 'chat',
- 'copilot',
- 'mothership',
- 'execution',
- 'workspace',
-])
-
-/**
- * Unified part identity sent by the client when completing a multipart upload.
- * `etag` is required for S3 and GCS (CompleteMultipartUpload). For Azure the
- * server derives the block id from `partNumber` via {@link deriveBlobBlockId}.
- */
-interface ClientCompletedPart {
- partNumber: number
- etag?: string
-}
-
-const isClientCompletedParts = (value: unknown): value is ClientCompletedPart[] =>
- Array.isArray(value) &&
- value.every(
- (p) =>
- p !== null &&
- typeof p === 'object' &&
- typeof (p as ClientCompletedPart).partNumber === 'number' &&
- ((p as ClientCompletedPart).etag === undefined ||
- typeof (p as ClientCompletedPart).etag === 'string')
- )
-
-const buildS3CustomConfig = (config: StorageConfig) =>
- config.bucket && config.region ? { bucket: config.bucket, region: config.region } : undefined
-
-const buildBlobCustomConfig = (config: StorageConfig) => ({
- containerName: config.containerName!,
- accountName: config.accountName!,
- accountKey: config.accountKey,
- connectionString: config.connectionString,
-})
-
-const buildGcsCustomConfig = (config: StorageConfig) =>
- config.bucket ? { bucket: config.bucket } : undefined
-
-const verifyTokenForUser = (token: string | undefined, userId: string) => {
- if (!token || typeof token !== 'string') {
- return null
- }
- const result = verifyUploadToken(token)
- if (!result.valid || result.payload.userId !== userId) {
- return null
- }
- return result.payload
-}
-
-/**
- * Record a trusted storage-key -> workspace ownership binding for completed
- * knowledge-base uploads. KB file authorization resolves the owning workspace
- * from this binding, so every KB object must have one. No-op for other contexts.
- */
-const recordKnowledgeBaseOwnership = async (
- payload: UploadTokenPayload,
- key: string
-): Promise => {
- if (payload.context !== 'knowledge-base' || !payload.workspaceId) {
- return
- }
- await recordKnowledgeBaseFileOwnership({
- key,
- userId: payload.userId,
- workspaceId: payload.workspaceId,
- originalName: payload.fileName ?? key.split('/').pop() ?? key,
- contentType: payload.contentType ?? 'application/octet-stream',
- size: typeof payload.fileSize === 'number' ? payload.fileSize : 0,
- })
-}
-
-export const POST = withRouteHandler(async (request: NextRequest) => {
- try {
- const session = await getSession()
- if (!session?.user?.id) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
- const userId = session.user.id
-
- const actionParam = request.nextUrl.searchParams.get('action')
- const actionResult = multipartActionSchema.safeParse(actionParam)
- const action = actionResult.success ? actionResult.data : null
-
- if (!isUsingCloudStorage()) {
- return NextResponse.json(
- {
- error:
- 'Multipart upload is only available with cloud storage (S3, Azure Blob, or Google Cloud Storage)',
- },
- { status: 400 }
- )
- }
-
- const storageProvider = getStorageProvider()
-
- switch (action) {
- case 'initiate': {
- const parsed = await parseRequest(
- initiateMultipartUploadContract,
- request,
- {},
- {
- validationErrorResponse: (error) =>
- NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }),
- }
- )
- if (!parsed.success) return parsed.response
-
- const data = parsed.data.body
- const { fileName, contentType, fileSize, workspaceId, context = 'knowledge-base' } = data
-
- if (!workspaceId || typeof workspaceId !== 'string') {
- return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
- }
-
- if (!ALLOWED_UPLOAD_CONTEXTS.has(context as StorageContext)) {
- return NextResponse.json({ error: 'Invalid storage context' }, { status: 400 })
- }
- const storageContext = context as StorageContext
-
- const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
- if (permission !== 'write' && permission !== 'admin') {
- return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
- }
-
- const config = getStorageConfig(storageContext)
-
- if (!QUOTA_EXEMPT_STORAGE_CONTEXTS.has(storageContext)) {
- const { checkStorageQuotaForBillingContext, resolveStorageBillingContext } = await import(
- '@/lib/billing/storage'
- )
- const storageBillingContext = await resolveStorageBillingContext(workspaceId)
- const quotaCheck = await checkStorageQuotaForBillingContext(
- storageBillingContext,
- fileSize ?? 0
- )
- if (!quotaCheck.allowed) {
- return NextResponse.json(
- { error: quotaCheck.error || 'Storage limit exceeded' },
- { status: 413 }
- )
- }
- }
-
- let customKey: string | undefined
- if (context === 'workspace' || context === 'mothership') {
- const { MAX_WORKSPACE_FILE_SIZE } = await import('@/lib/uploads/shared/types')
- if (typeof fileSize === 'number' && fileSize > MAX_WORKSPACE_FILE_SIZE) {
- return NextResponse.json(
- { error: `File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes` },
- { status: 413 }
- )
- }
-
- const { generateWorkspaceFileKey } = await import(
- '@/lib/uploads/contexts/workspace/workspace-file-manager'
- )
- customKey = generateWorkspaceFileKey(workspaceId, fileName)
- } else if (context === 'execution') {
- const workflowId = (data as { workflowId?: unknown }).workflowId
- const executionId = (data as { executionId?: unknown }).executionId
- if (typeof workflowId !== 'string' || !workflowId.trim()) {
- return NextResponse.json(
- { error: 'workflowId is required for execution uploads' },
- { status: 400 }
- )
- }
- if (typeof executionId !== 'string' || !executionId.trim()) {
- return NextResponse.json(
- { error: 'executionId is required for execution uploads' },
- { status: 400 }
- )
- }
- const { generateExecutionFileKey } = await import(
- '@/lib/uploads/contexts/execution/utils'
- )
- customKey = generateExecutionFileKey({ workspaceId, workflowId, executionId }, fileName)
- }
-
- let uploadId: string
- let key: string
-
- if (storageProvider === 's3') {
- const { initiateS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client')
- const result = await initiateS3MultipartUpload({
- fileName,
- contentType,
- fileSize,
- customConfig: buildS3CustomConfig(config),
- customKey,
- purpose: context,
- })
- uploadId = result.uploadId
- key = result.key
- } else if (storageProvider === 'blob') {
- const { initiateMultipartUpload } = await import('@/lib/uploads/providers/blob/client')
- const result = await initiateMultipartUpload({
- fileName,
- contentType,
- fileSize,
- customConfig: buildBlobCustomConfig(config),
- customKey,
- })
- uploadId = result.uploadId
- key = result.key
- } else if (storageProvider === 'gcs') {
- const { initiateGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client')
- const result = await initiateGcsMultipartUpload({
- fileName,
- contentType,
- fileSize,
- customConfig: buildGcsCustomConfig(config),
- customKey,
- purpose: context,
- })
- uploadId = result.uploadId
- key = result.key
- } else {
- return NextResponse.json(
- { error: `Unsupported storage provider: ${storageProvider}` },
- { status: 400 }
- )
- }
-
- const uploadToken = signUploadToken({
- uploadId,
- key,
- userId,
- workspaceId,
- context: storageContext,
- fileName,
- contentType,
- ...(typeof fileSize === 'number' ? { fileSize } : {}),
- })
-
- logger.info(
- `Initiated ${storageProvider} multipart upload for ${fileName} (context: ${storageContext}, workspace: ${workspaceId}): ${uploadId}`
- )
-
- return NextResponse.json({ uploadId, key, uploadToken })
- }
-
- case 'get-part-urls': {
- const parsed = await parseRequest(
- getMultipartPartUrlsContract,
- request,
- {},
- {
- validationErrorResponse: (error) =>
- NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }),
- }
- )
- if (!parsed.success) return parsed.response
-
- const data = parsed.data.body
- const { partNumbers } = data
-
- const tokenPayload = verifyTokenForUser(data.uploadToken, userId)
- if (!tokenPayload) {
- return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 })
- }
-
- const { uploadId, key, context } = tokenPayload
- const config = getStorageConfig(context)
-
- if (storageProvider === 's3') {
- const { getS3MultipartPartUrls } = await import('@/lib/uploads/providers/s3/client')
- const presignedUrls = await getS3MultipartPartUrls(
- key,
- uploadId,
- partNumbers,
- buildS3CustomConfig(config)
- )
- return NextResponse.json({ presignedUrls })
- }
- if (storageProvider === 'blob') {
- const { getMultipartPartUrls } = await import('@/lib/uploads/providers/blob/client')
- const presignedUrls = await getMultipartPartUrls(
- key,
- partNumbers,
- buildBlobCustomConfig(config)
- )
- return NextResponse.json({ presignedUrls })
- }
- if (storageProvider === 'gcs') {
- const { getGcsMultipartPartUrls } = await import('@/lib/uploads/providers/gcs/client')
- const presignedUrls = await getGcsMultipartPartUrls(
- key,
- uploadId,
- partNumbers,
- buildGcsCustomConfig(config)
- )
- return NextResponse.json({ presignedUrls })
- }
-
- return NextResponse.json(
- { error: `Unsupported storage provider: ${storageProvider}` },
- { status: 400 }
- )
- }
-
- case 'complete': {
- const parsed = await parseRequest(
- completeMultipartUploadContract,
- request,
- {},
- {
- validationErrorResponse: (error) =>
- NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }),
- }
- )
- if (!parsed.success) return parsed.response
-
- const data: CompleteMultipartBody = parsed.data.body
-
- const s3Module =
- storageProvider === 's3' ? await import('@/lib/uploads/providers/s3/client') : null
- const blobModule =
- storageProvider === 'blob' ? await import('@/lib/uploads/providers/blob/client') : null
- const gcsModule =
- storageProvider === 'gcs' ? await import('@/lib/uploads/providers/gcs/client') : null
-
- const completeOne = async (payload: UploadTokenPayload, parts: ClientCompletedPart[]) => {
- const { uploadId, key, context } = payload
- const config = getStorageConfig(context)
-
- let completed: { location: string; path: string; key: string }
- if (storageProvider === 's3' && s3Module) {
- const { completeS3MultipartUpload } = s3Module
- const s3Parts = parts.map((p) => {
- if (!p.etag) {
- throw new Error(`Missing etag for S3 part ${p.partNumber}`)
- }
- return { ETag: p.etag, PartNumber: p.partNumber }
- })
- completed = await completeS3MultipartUpload(
- key,
- uploadId,
- s3Parts,
- buildS3CustomConfig(config)
- )
- } else if (storageProvider === 'blob' && blobModule) {
- const { completeMultipartUpload, deriveBlobBlockId } = blobModule
- const blobParts = parts.map((p) => ({
- partNumber: p.partNumber,
- blockId: deriveBlobBlockId(p.partNumber),
- }))
- completed = await completeMultipartUpload(key, blobParts, buildBlobCustomConfig(config))
- } else if (storageProvider === 'gcs' && gcsModule) {
- const { completeGcsMultipartUpload } = gcsModule
- const gcsParts = parts.map((p) => {
- if (!p.etag) {
- throw new Error(`Missing etag for GCS part ${p.partNumber}`)
- }
- return { ETag: p.etag, PartNumber: p.partNumber }
- })
- completed = await completeGcsMultipartUpload(
- key,
- uploadId,
- gcsParts,
- buildGcsCustomConfig(config)
- )
- } else {
- throw new Error(`Unsupported storage provider: ${storageProvider}`)
- }
-
- try {
- await recordKnowledgeBaseOwnership(payload, completed.key)
- } catch (error) {
- // The object is committed, but without an ownership binding a KB file
- // is unreadable and undeletable via the KB paths. Remove the orphan
- // best-effort and surface a retryable error so the client re-uploads.
- if (payload.context === 'knowledge-base') {
- await deleteFile({ key: completed.key, context: 'knowledge-base' }).catch(() => {})
- }
- throw error
- }
-
- return {
- success: true as const,
- location: completed.location,
- path: completed.path,
- key: completed.key,
- }
- }
-
- if ('uploads' in data && Array.isArray(data.uploads)) {
- const verified: Array<{ payload: UploadTokenPayload; parts: ClientCompletedPart[] }> = []
- for (const upload of data.uploads) {
- const payload = verifyTokenForUser(upload.uploadToken, userId)
- if (!payload) {
- return NextResponse.json(
- { error: 'Invalid or expired upload token' },
- { status: 403 }
- )
- }
- if (!isClientCompletedParts(upload.parts)) {
- return NextResponse.json(
- { error: 'Invalid parts payload: expected [{ partNumber, etag? }]' },
- { status: 400 }
- )
- }
- verified.push({ payload, parts: upload.parts })
- }
-
- const results = await Promise.all(
- verified.map(({ payload, parts }) => completeOne(payload, parts))
- )
-
- logger.info(`Completed ${verified.length} multipart uploads`)
- return NextResponse.json({ results })
- }
-
- const single = data
- const tokenPayload = verifyTokenForUser(single.uploadToken, userId)
- if (!tokenPayload) {
- return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 })
- }
- if (!isClientCompletedParts(single.parts)) {
- return NextResponse.json(
- { error: 'Invalid parts payload: expected [{ partNumber, etag? }]' },
- { status: 400 }
- )
- }
-
- const result = await completeOne(tokenPayload, single.parts)
- logger.info(
- `Completed ${storageProvider} multipart upload for key ${tokenPayload.key} (context: ${tokenPayload.context})`
- )
- return NextResponse.json(result)
- }
-
- case 'abort': {
- const parsed = await parseRequest(
- abortMultipartUploadContract,
- request,
- {},
- {
- validationErrorResponse: (error) =>
- NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }),
- }
- )
- if (!parsed.success) return parsed.response
-
- const data = parsed.data.body
- const tokenPayload = verifyTokenForUser(data.uploadToken, userId)
- if (!tokenPayload) {
- return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 })
- }
-
- const { uploadId, key, context } = tokenPayload
- const config = getStorageConfig(context)
-
- if (storageProvider === 's3') {
- const { abortS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client')
- await abortS3MultipartUpload(key, uploadId, buildS3CustomConfig(config))
- logger.info(`Aborted S3 multipart upload for key ${key} (context: ${context})`)
- } else if (storageProvider === 'blob') {
- const { abortMultipartUpload } = await import('@/lib/uploads/providers/blob/client')
- await abortMultipartUpload(key, buildBlobCustomConfig(config))
- logger.info(`Aborted Azure multipart upload for key ${key} (context: ${context})`)
- } else if (storageProvider === 'gcs') {
- const { abortGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client')
- await abortGcsMultipartUpload(key, uploadId, buildGcsCustomConfig(config))
- logger.info(`Aborted GCS multipart upload for key ${key} (context: ${context})`)
- } else {
- return NextResponse.json(
- { error: `Unsupported storage provider: ${storageProvider}` },
- { status: 400 }
- )
- }
-
- return NextResponse.json({ success: true })
- }
-
- default:
- return NextResponse.json(
- { error: 'Invalid action. Use: initiate, get-part-urls, complete, or abort' },
- { status: 400 }
- )
- }
- } catch (error) {
- logger.error('Multipart upload error:', error)
- return NextResponse.json(
- { error: getErrorMessage(error, 'Multipart upload failed') },
- { status: 500 }
- )
- }
-})
diff --git a/apps/sim/app/api/files/presigned/batch/route.test.ts b/apps/sim/app/api/files/presigned/batch/route.test.ts
deleted file mode 100644
index 988fae9cce4..00000000000
--- a/apps/sim/app/api/files/presigned/batch/route.test.ts
+++ /dev/null
@@ -1,189 +0,0 @@
-/**
- * Tests for the batch presigned upload API route
- *
- * @vitest-environment node
- */
-
-import { authMockFns, storageServiceMock, storageServiceMockFns } from '@sim/testing'
-import { NextRequest } from 'next/server'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-
-const {
- mockValidateFileType,
- mockGetUserEntityPermissions,
- mockRecordKnowledgeBaseFileOwnershipMany,
-} = vi.hoisted(() => ({
- mockValidateFileType: vi.fn().mockReturnValue(null),
- mockGetUserEntityPermissions: vi.fn().mockResolvedValue('write'),
- mockRecordKnowledgeBaseFileOwnershipMany: vi.fn().mockResolvedValue(undefined),
-}))
-
-vi.mock('@/lib/uploads/config', () => ({
- getServeStoragePrefix: () => 's3',
-}))
-
-vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
-
-vi.mock('@/lib/uploads/utils/validation', () => ({
- validateFileType: mockValidateFileType,
- SUPPORTED_ARCHIVE_EXTENSIONS: ['zip'] as const,
-}))
-
-vi.mock('@/lib/workspaces/permissions/utils', () => ({
- getUserEntityPermissions: mockGetUserEntityPermissions,
-}))
-
-vi.mock('@/lib/uploads/server/metadata', () => ({
- recordKnowledgeBaseFileOwnershipMany: mockRecordKnowledgeBaseFileOwnershipMany,
-}))
-
-import { POST } from '@/app/api/files/presigned/batch/route'
-
-const KB_QUERY = 'type=knowledge-base&workspaceId=ws-1'
-
-const buildRequest = (query: string, files?: unknown) =>
- new NextRequest(`http://localhost:3000/api/files/presigned/batch?${query}`, {
- method: 'POST',
- body: JSON.stringify({
- files: files ?? [{ fileName: 'doc.pdf', contentType: 'application/pdf', fileSize: 1024 }],
- }),
- })
-
-describe('/api/files/presigned/batch', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
- mockValidateFileType.mockReturnValue(null)
- mockGetUserEntityPermissions.mockResolvedValue('write')
- mockRecordKnowledgeBaseFileOwnershipMany.mockResolvedValue(undefined)
- storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true)
- storageServiceMockFns.mockGenerateBatchPresignedUploadUrls.mockImplementation(
- async (files: Array<{ fileName: string }>, context: string) =>
- files.map((file) => ({
- url: `https://example.com/${context}/${file.fileName}`,
- key: `${context}/${file.fileName}`,
- }))
- )
- })
-
- it('returns 401 when the caller has no session', async () => {
- authMockFns.mockGetSession.mockResolvedValue(null)
-
- const response = await POST(buildRequest(KB_QUERY))
-
- expect(response.status).toBe(401)
- expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled()
- })
-
- it.each([
- 'workspace-logos',
- 'profile-pictures',
- 'execution',
- 'mothership',
- 'chat',
- 'copilot',
- 'workspace',
- ])('refuses to presign the %s context', async (type) => {
- const response = await POST(buildRequest(`type=${type}&workspaceId=ws-1`))
- const data = await response.json()
-
- expect(response.status).toBe(400)
- expect(data.error).toContain('Invalid type parameter')
- expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled()
- })
-
- it('returns 400 when type is missing', async () => {
- const response = await POST(buildRequest('workspaceId=ws-1'))
-
- expect(response.status).toBe(400)
- expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled()
- })
-
- it('returns 400 when workspaceId is missing', async () => {
- const response = await POST(buildRequest('type=knowledge-base'))
- const data = await response.json()
-
- expect(response.status).toBe(400)
- expect(data.error).toContain('workspaceId')
- expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
- expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled()
- })
-
- it.each([['read'], [null]])(
- 'returns 403 when the caller has %s access to the workspace',
- async (permission) => {
- mockGetUserEntityPermissions.mockResolvedValue(permission)
-
- const response = await POST(buildRequest(KB_QUERY))
-
- expect(response.status).toBe(403)
- expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled()
- }
- )
-
- it('authorizes the workspace before returning the local-storage fallback', async () => {
- storageServiceMockFns.mockHasCloudStorage.mockReturnValue(false)
- mockGetUserEntityPermissions.mockResolvedValue('read')
-
- const response = await POST(buildRequest(KB_QUERY))
-
- expect(response.status).toBe(403)
- })
-
- it('rejects unsupported file types before minting any URL', async () => {
- mockValidateFileType.mockReturnValue({
- code: 'UNSUPPORTED_FILE_TYPE',
- message: 'Unsupported file type: html.',
- supportedTypes: ['pdf'],
- })
-
- const response = await POST(
- buildRequest(KB_QUERY, [{ fileName: 'poc.html', contentType: 'text/html', fileSize: 41 }])
- )
- const data = await response.json()
-
- expect(response.status).toBe(400)
- expect(data.code).toBe('UNSUPPORTED_FILE_TYPE')
- expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled()
- })
-
- it('mints knowledge-base URLs and records workspace ownership for a permitted caller', async () => {
- const response = await POST(buildRequest(KB_QUERY))
- const data = await response.json()
-
- expect(response.status).toBe(200)
- expect(mockGetUserEntityPermissions).toHaveBeenCalledWith('user-1', 'workspace', 'ws-1')
- expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).toHaveBeenCalledWith(
- [{ fileName: 'doc.pdf', contentType: 'application/pdf', fileSize: 1024 }],
- 'knowledge-base',
- 'user-1',
- 3600
- )
- expect(data.files).toHaveLength(1)
- expect(data.files[0].fileInfo.key).toBe('knowledge-base/doc.pdf')
- expect(data.files[0].fileInfo.path).toContain('?context=knowledge-base')
- expect(data.directUploadSupported).toBe(true)
- expect(mockRecordKnowledgeBaseFileOwnershipMany).toHaveBeenCalledWith([
- {
- key: 'knowledge-base/doc.pdf',
- userId: 'user-1',
- workspaceId: 'ws-1',
- originalName: 'doc.pdf',
- contentType: 'application/pdf',
- size: 1024,
- },
- ])
- })
-
- it('returns the fallback response when cloud storage is not configured', async () => {
- storageServiceMockFns.mockHasCloudStorage.mockReturnValue(false)
-
- const response = await POST(buildRequest(KB_QUERY))
- const data = await response.json()
-
- expect(response.status).toBe(200)
- expect(data.directUploadSupported).toBe(false)
- expect(data.files[0].presignedUrl).toBe('')
- expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled()
- })
-})
diff --git a/apps/sim/app/api/files/presigned/batch/route.ts b/apps/sim/app/api/files/presigned/batch/route.ts
deleted file mode 100644
index 226fdc9ed87..00000000000
--- a/apps/sim/app/api/files/presigned/batch/route.ts
+++ /dev/null
@@ -1,184 +0,0 @@
-import { createLogger } from '@sim/logger'
-import { type NextRequest, NextResponse } from 'next/server'
-import {
- batchPresignedUploadBodyContract,
- batchPresignedUploadTypeSchema,
- batchPresignedUploadTypes,
-} from '@/lib/api/contracts/storage-transfer'
-import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
-import { getSession } from '@/lib/auth'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { getServeStoragePrefix } from '@/lib/uploads/config'
-import {
- generateBatchPresignedUploadUrls,
- hasCloudStorage,
-} from '@/lib/uploads/core/storage-service'
-import { recordKnowledgeBaseFileOwnershipMany } from '@/lib/uploads/server/metadata'
-import { validateFileType } from '@/lib/uploads/utils/validation'
-import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
-import { createErrorResponse } from '@/app/api/files/utils'
-
-const logger = createLogger('BatchPresignedUploadAPI')
-
-/**
- * Mints presigned upload URLs for knowledge-base ingest, the only context this
- * endpoint can authorize. Every request must name a workspace the caller has
- * write access to; other storage contexts are rejected rather than presigned,
- * because a presigned PUT is a write grant into a bucket served from a trusted
- * origin.
- */
-export const POST = withRouteHandler(async (request: NextRequest) => {
- try {
- const session = await getSession()
- if (!session?.user?.id) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const parsed = await parseRequest(
- batchPresignedUploadBodyContract,
- request,
- {},
- {
- validationErrorResponse: (error) =>
- NextResponse.json(
- { error: getValidationErrorMessage(error, 'Invalid request data') },
- { status: 400 }
- ),
- invalidJsonResponse: () =>
- NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }),
- }
- )
- if (!parsed.success) return parsed.response
-
- const { files } = parsed.data.body
-
- const uploadTypeParam = request.nextUrl.searchParams.get('type')
- if (!uploadTypeParam) {
- return NextResponse.json({ error: 'type query parameter is required' }, { status: 400 })
- }
-
- const uploadTypeResult = batchPresignedUploadTypeSchema.safeParse(uploadTypeParam)
- if (!uploadTypeResult.success) {
- return NextResponse.json(
- {
- error: `Invalid type parameter. Must be one of: ${batchPresignedUploadTypes.join(', ')}`,
- },
- { status: 400 }
- )
- }
-
- const uploadType = uploadTypeResult.data
- const sessionUserId = session.user.id
-
- for (const file of files) {
- const fileValidationError = validateFileType(file.fileName, file.contentType)
- if (fileValidationError) {
- return NextResponse.json(
- {
- error: fileValidationError.message,
- code: fileValidationError.code,
- supportedTypes: fileValidationError.supportedTypes,
- },
- { status: 400 }
- )
- }
- }
-
- const workspaceId = request.nextUrl.searchParams.get('workspaceId')
- if (!workspaceId?.trim()) {
- return NextResponse.json(
- { error: 'workspaceId query parameter is required for knowledge-base uploads' },
- { status: 400 }
- )
- }
-
- const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId)
- if (permission !== 'write' && permission !== 'admin') {
- return NextResponse.json(
- { error: 'Write or Admin access required for knowledge-base uploads' },
- { status: 403 }
- )
- }
-
- if (!hasCloudStorage()) {
- logger.info(
- `Local storage detected - batch presigned URLs not available, client will use API fallback`
- )
- return NextResponse.json({
- files: files.map((file) => ({
- fileName: file.fileName,
- presignedUrl: '', // Empty URL signals fallback to API upload
- fileInfo: {
- path: '',
- key: '',
- name: file.fileName,
- size: file.fileSize,
- type: file.contentType,
- },
- directUploadSupported: false,
- })),
- directUploadSupported: false,
- })
- }
-
- logger.info(`Generating batch ${uploadType} presigned URLs for ${files.length} files`)
-
- const startTime = Date.now()
-
- const presignedUrls = await generateBatchPresignedUploadUrls(
- files.map((file) => ({
- fileName: file.fileName,
- contentType: file.contentType,
- fileSize: file.fileSize,
- })),
- uploadType,
- sessionUserId,
- 3600 // 1 hour
- )
-
- const duration = Date.now() - startTime
- logger.info(
- `Generated ${files.length} presigned URLs in ${duration}ms (avg ${Math.round(duration / files.length)}ms per file)`
- )
-
- await recordKnowledgeBaseFileOwnershipMany(
- presignedUrls.map((urlResponse, index) => ({
- key: urlResponse.key,
- userId: sessionUserId,
- workspaceId,
- originalName: files[index].fileName,
- contentType: files[index].contentType,
- size: files[index].fileSize,
- }))
- )
-
- const storagePrefix = getServeStoragePrefix()
-
- return NextResponse.json({
- files: presignedUrls.map((urlResponse, index) => {
- const finalPath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(urlResponse.key)}?context=${uploadType}`
- const file = files[index]
-
- return {
- fileName: file.fileName,
- presignedUrl: urlResponse.url,
- fileInfo: {
- path: finalPath,
- key: urlResponse.key,
- name: file.fileName,
- size: file.fileSize,
- type: file.contentType,
- },
- uploadHeaders: urlResponse.uploadHeaders,
- directUploadSupported: true,
- }
- }),
- directUploadSupported: true,
- })
- } catch (error) {
- logger.error('Error generating batch presigned URLs:', error)
- return createErrorResponse(
- error instanceof Error ? error : new Error('Failed to generate batch presigned URLs')
- )
- }
-})
diff --git a/apps/sim/app/api/files/presigned/route.test.ts b/apps/sim/app/api/files/presigned/route.test.ts
deleted file mode 100644
index 3674ac70b76..00000000000
--- a/apps/sim/app/api/files/presigned/route.test.ts
+++ /dev/null
@@ -1,937 +0,0 @@
-/**
- * Tests for file presigned API route
- *
- * @vitest-environment node
- */
-
-import { authMockFns, storageServiceMock, storageServiceMockFns } from '@sim/testing'
-import { NextRequest } from 'next/server'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-
-const {
- mockVerifyFileAccess,
- mockVerifyWorkspaceFileAccess,
- mockUseBlobStorage,
- mockUseS3Storage,
- mockGetStorageConfig,
- mockIsUsingCloudStorage,
- mockGetStorageProvider,
- mockValidateFileType,
- mockValidateAttachmentFileType,
- mockGenerateCopilotUploadUrl,
- mockIsImageFileType,
- mockGetStorageProviderUploads,
- mockIsUsingCloudStorageUploads,
- mockGetUserEntityPermissions,
- mockGenerateWorkspaceFileKey,
- mockGenerateExecutionFileKey,
- mockInsertFileMetadata,
- mockCheckStorageQuotaForBillingContext,
- mockDecrementStorageUsageForBillingContext,
- mockIncrementStorageUsageForBillingContext,
- mockResolveStorageBillingContext,
-} = vi.hoisted(() => ({
- mockVerifyFileAccess: vi.fn().mockResolvedValue(true),
- mockVerifyWorkspaceFileAccess: vi.fn().mockResolvedValue(true),
- mockUseBlobStorage: { value: false },
- mockUseS3Storage: { value: true },
- mockGetStorageConfig: vi.fn(),
- mockIsUsingCloudStorage: vi.fn(),
- mockGetStorageProvider: vi.fn(),
- mockValidateFileType: vi.fn().mockReturnValue(null),
- mockValidateAttachmentFileType: vi.fn().mockReturnValue(null),
- mockGenerateCopilotUploadUrl: vi.fn().mockResolvedValue({
- url: 'https://example.com/presigned-url',
- key: 'copilot/test-key.txt',
- }),
- mockIsImageFileType: vi.fn().mockReturnValue(true),
- mockGetStorageProviderUploads: vi.fn(),
- mockIsUsingCloudStorageUploads: vi.fn(),
- mockGetUserEntityPermissions: vi.fn().mockResolvedValue('admin'),
- mockGenerateWorkspaceFileKey: vi.fn(
- (workspaceId: string, fileName: string) => `workspace/${workspaceId}/${fileName}`
- ),
- mockGenerateExecutionFileKey: vi.fn(
- (ctx: { workspaceId: string; workflowId: string; executionId: string }, fileName: string) =>
- `execution/${ctx.workspaceId}/${ctx.workflowId}/${ctx.executionId}/${fileName}`
- ),
- mockInsertFileMetadata: vi.fn().mockResolvedValue({ id: 'wf_test' }),
- mockCheckStorageQuotaForBillingContext: vi.fn(),
- mockDecrementStorageUsageForBillingContext: vi.fn(),
- mockIncrementStorageUsageForBillingContext: vi.fn(),
- mockResolveStorageBillingContext: vi.fn(),
-}))
-
-vi.mock('@/app/api/files/authorization', () => ({
- verifyFileAccess: mockVerifyFileAccess,
- verifyWorkspaceFileAccess: mockVerifyWorkspaceFileAccess,
-}))
-
-vi.mock('@/lib/uploads/config', () => ({
- get USE_BLOB_STORAGE() {
- return mockUseBlobStorage.value
- },
- get USE_S3_STORAGE() {
- return mockUseS3Storage.value
- },
- UPLOAD_DIR: '/uploads',
- getServeStoragePrefix: () => (mockUseBlobStorage.value ? 'blob' : 's3'),
- getStorageConfig: mockGetStorageConfig,
- isUsingCloudStorage: mockIsUsingCloudStorage,
- getStorageProvider: mockGetStorageProvider,
-}))
-
-vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
-
-vi.mock('@/lib/billing/storage', () => ({
- checkStorageQuotaForBillingContext: mockCheckStorageQuotaForBillingContext,
- decrementStorageUsageForBillingContext: mockDecrementStorageUsageForBillingContext,
- incrementStorageUsageForBillingContext: mockIncrementStorageUsageForBillingContext,
- resolveStorageBillingContext: mockResolveStorageBillingContext,
-}))
-
-vi.mock('@/lib/uploads/utils/validation', () => ({
- validateFileType: mockValidateFileType,
- validateAttachmentFileType: mockValidateAttachmentFileType,
-}))
-
-vi.mock('@/lib/workspaces/permissions/utils', () => ({
- getUserEntityPermissions: mockGetUserEntityPermissions,
-}))
-
-vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
- generateWorkspaceFileKey: mockGenerateWorkspaceFileKey,
-}))
-
-vi.mock('@/lib/uploads/contexts/execution/utils', () => ({
- generateExecutionFileKey: mockGenerateExecutionFileKey,
-}))
-
-vi.mock('@/lib/uploads/server/metadata', () => ({
- insertFileMetadata: mockInsertFileMetadata,
- recordKnowledgeBaseFileOwnership: (ownership: Record) =>
- mockInsertFileMetadata({ ...ownership, context: 'knowledge-base' }),
-}))
-
-vi.mock('@/lib/uploads/utils/file-utils', () => ({
- isImageFileType: mockIsImageFileType,
-}))
-
-vi.mock('@/lib/uploads', () => ({
- CopilotFiles: {
- generateCopilotUploadUrl: mockGenerateCopilotUploadUrl,
- },
- getStorageProvider: mockGetStorageProviderUploads,
- isUsingCloudStorage: mockIsUsingCloudStorageUploads,
-}))
-
-import { POST } from '@/app/api/files/presigned/route'
-
-const defaultMockUser = {
- id: 'test-user-id',
- name: 'Test User',
- email: 'test@example.com',
-}
-
-function setupFileApiMocks(
- options: {
- authenticated?: boolean
- storageProvider?: 's3' | 'blob' | 'local'
- cloudEnabled?: boolean
- } = {}
-) {
- const { authenticated = true, storageProvider = 's3', cloudEnabled = true } = options
-
- if (authenticated) {
- authMockFns.mockGetSession.mockResolvedValue({ user: defaultMockUser })
- } else {
- authMockFns.mockGetSession.mockResolvedValue(null)
- }
-
- const useBlobStorage = storageProvider === 'blob' && cloudEnabled
- const useS3Storage = storageProvider === 's3' && cloudEnabled
-
- mockUseBlobStorage.value = useBlobStorage
- mockUseS3Storage.value = useS3Storage
-
- mockGetStorageConfig.mockReturnValue(
- useBlobStorage
- ? {
- accountName: 'testaccount',
- accountKey: 'testkey',
- connectionString: 'testconnection',
- containerName: 'testcontainer',
- }
- : {
- bucket: 'test-bucket',
- region: 'us-east-1',
- }
- )
- mockIsUsingCloudStorage.mockReturnValue(cloudEnabled)
- mockGetStorageProvider.mockReturnValue(
- storageProvider === 'blob' ? 'Azure Blob' : storageProvider === 's3' ? 'S3' : 'Local'
- )
-
- storageServiceMockFns.mockHasCloudStorage.mockReturnValue(cloudEnabled)
- storageServiceMockFns.mockGeneratePresignedUploadUrl.mockImplementation(
- async (opts: { fileName: string; context: string; customKey?: string }) => {
- const timestamp = Date.now()
- const safeFileName = opts.fileName.replace(/[^a-zA-Z0-9.-]/g, '_')
- const key = opts.customKey ?? `${opts.context}/${timestamp}-ik3a6w4-${safeFileName}`
- return {
- url: 'https://example.com/presigned-url',
- key,
- }
- }
- )
- storageServiceMockFns.mockGeneratePresignedDownloadUrl.mockResolvedValue(
- 'https://example.com/presigned-url'
- )
-
- mockValidateFileType.mockReturnValue(null)
- mockValidateAttachmentFileType.mockReturnValue(null)
- mockGetUserEntityPermissions.mockResolvedValue('admin')
-
- mockGetStorageProviderUploads.mockReturnValue(
- storageProvider === 'blob' ? 'Azure Blob' : storageProvider === 's3' ? 'S3' : 'Local'
- )
- mockIsUsingCloudStorageUploads.mockReturnValue(cloudEnabled)
-}
-
-describe('/api/files/presigned', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- vi.useFakeTimers()
- vi.setSystemTime(new Date('2024-01-01T00:00:00Z'))
-
- vi.stubGlobal('crypto', {
- randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'),
- })
- })
-
- afterEach(() => {
- vi.useRealTimers()
- })
-
- describe('POST', () => {
- it('should return graceful fallback response when cloud storage is not enabled', async () => {
- setupFileApiMocks({
- cloudEnabled: false,
- storageProvider: 's3',
- })
-
- const request = new NextRequest(
- 'http://localhost:3000/api/files/presigned?type=profile-pictures',
- {
- method: 'POST',
- body: JSON.stringify({
- fileName: 'avatar.png',
- contentType: 'image/png',
- fileSize: 1024,
- }),
- }
- )
-
- const response = await POST(request)
- const data = await response.json()
-
- expect(response.status).toBe(200)
- expect(data.directUploadSupported).toBe(false)
- expect(data.presignedUrl).toBe('')
- expect(data.fileName).toBe('avatar.png')
- expect(data.fileInfo).toBeDefined()
- expect(data.fileInfo.name).toBe('avatar.png')
- expect(data.fileInfo.size).toBe(1024)
- expect(data.fileInfo.type).toBe('image/png')
- })
-
- it('should return error when fileName is missing', async () => {
- setupFileApiMocks({
- cloudEnabled: true,
- storageProvider: 's3',
- })
-
- const request = new NextRequest('http://localhost:3000/api/files/presigned', {
- method: 'POST',
- body: JSON.stringify({
- contentType: 'text/plain',
- fileSize: 1024,
- }),
- })
-
- const response = await POST(request)
- const data = await response.json()
-
- expect(response.status).toBe(400)
- expect(data.error).toBe('fileName is required and cannot be empty')
- expect(data.code).toBe('VALIDATION_ERROR')
- })
-
- it('should return error when contentType is missing', async () => {
- setupFileApiMocks({
- cloudEnabled: true,
- storageProvider: 's3',
- })
-
- const request = new NextRequest('http://localhost:3000/api/files/presigned', {
- method: 'POST',
- body: JSON.stringify({
- fileName: 'test.txt',
- fileSize: 1024,
- }),
- })
-
- const response = await POST(request)
- const data = await response.json()
-
- expect(response.status).toBe(400)
- expect(data.error).toBe('contentType is required and cannot be empty')
- expect(data.code).toBe('VALIDATION_ERROR')
- })
-
- it('should return error when fileSize is invalid', async () => {
- setupFileApiMocks({
- cloudEnabled: true,
- storageProvider: 's3',
- })
-
- const request = new NextRequest('http://localhost:3000/api/files/presigned', {
- method: 'POST',
- body: JSON.stringify({
- fileName: 'test.txt',
- contentType: 'text/plain',
- fileSize: 0,
- }),
- })
-
- const response = await POST(request)
- const data = await response.json()
-
- expect(response.status).toBe(400)
- expect(data.error).toBe('fileSize must be a positive number')
- expect(data.code).toBe('VALIDATION_ERROR')
- })
-
- it('should return error when file size exceeds limit', async () => {
- setupFileApiMocks({
- cloudEnabled: true,
- storageProvider: 's3',
- })
-
- const largeFileSize = 150 * 1024 * 1024 // 150MB (exceeds 100MB limit)
- const request = new NextRequest('http://localhost:3000/api/files/presigned', {
- method: 'POST',
- body: JSON.stringify({
- fileName: 'large-file.txt',
- contentType: 'text/plain',
- fileSize: largeFileSize,
- }),
- })
-
- const response = await POST(request)
- const data = await response.json()
-
- expect(response.status).toBe(400)
- expect(data.error).toContain('exceeds maximum allowed size')
- expect(data.code).toBe('VALIDATION_ERROR')
- })
-
- it('should generate S3 presigned URL successfully', async () => {
- setupFileApiMocks({
- cloudEnabled: true,
- storageProvider: 's3',
- })
-
- const request = new NextRequest(
- 'http://localhost:3000/api/files/presigned?type=profile-pictures',
- {
- method: 'POST',
- body: JSON.stringify({
- fileName: 'test avatar.png',
- contentType: 'image/png',
- fileSize: 1024,
- }),
- }
- )
-
- const response = await POST(request)
- const data = await response.json()
-
- expect(response.status).toBe(200)
- expect(data.presignedUrl).toBe('https://example.com/presigned-url')
- expect(data.fileInfo).toMatchObject({
- path: expect.stringMatching(/\/api\/files\/serve\/s3\/.+\?context=profile-pictures$/),
- key: expect.stringMatching(/.*test.avatar\.png$/),
- name: 'test avatar.png',
- size: 1024,
- type: 'image/png',
- })
- expect(data.directUploadSupported).toBe(true)
- })
-
- it('should generate knowledge-base S3 presigned URL with kb prefix', async () => {
- setupFileApiMocks({
- cloudEnabled: true,
- storageProvider: 's3',
- })
-
- const request = new NextRequest(
- 'http://localhost:3000/api/files/presigned?type=knowledge-base&workspaceId=ws-1',
- {
- method: 'POST',
- body: JSON.stringify({
- fileName: 'knowledge-doc.pdf',
- contentType: 'application/pdf',
- fileSize: 2048,
- }),
- }
- )
-
- const response = await POST(request)
- const data = await response.json()
-
- expect(response.status).toBe(200)
- expect(data.fileInfo.key).toMatch(/^kb\/.*knowledge-doc\.pdf$/)
- expect(data.directUploadSupported).toBe(true)
- })
-
- it('should generate profile-pictures S3 presigned URL with its prefix and direct path', async () => {
- setupFileApiMocks({
- cloudEnabled: true,
- storageProvider: 's3',
- })
-
- const request = new NextRequest(
- 'http://localhost:3000/api/files/presigned?type=profile-pictures',
- {
- method: 'POST',
- body: JSON.stringify({
- fileName: 'avatar.png',
- contentType: 'image/png',
- fileSize: 4096,
- }),
- }
- )
-
- const response = await POST(request)
- const data = await response.json()
-
- expect(response.status).toBe(200)
- expect(data.fileInfo.key).toMatch(/^profile-pictures\/.*avatar\.png$/)
- expect(data.fileInfo.path).toMatch(/\/api\/files\/serve\/s3\/.+\?context=profile-pictures$/)
- expect(data.presignedUrl).toBeTruthy()
- expect(data.directUploadSupported).toBe(true)
- })
-
- it('should generate Azure Blob presigned URL successfully', async () => {
- setupFileApiMocks({
- cloudEnabled: true,
- storageProvider: 'blob',
- })
-
- const request = new NextRequest(
- 'http://localhost:3000/api/files/presigned?type=profile-pictures',
- {
- method: 'POST',
- body: JSON.stringify({
- fileName: 'test avatar.png',
- contentType: 'image/png',
- fileSize: 1024,
- }),
- }
- )
-
- const response = await POST(request)
- const data = await response.json()
-
- expect(response.status).toBe(200)
- expect(data.presignedUrl).toBeTruthy()
- expect(typeof data.presignedUrl).toBe('string')
- expect(data.fileInfo).toMatchObject({
- key: expect.stringMatching(/.*test.avatar\.png$/),
- name: 'test avatar.png',
- size: 1024,
- type: 'image/png',
- })
- expect(data.directUploadSupported).toBe(true)
- })
-
- it('should generate profile-pictures Azure Blob presigned URL with its prefix and direct path', async () => {
- setupFileApiMocks({
- cloudEnabled: true,
- storageProvider: 'blob',
- })
-
- const request = new NextRequest(
- 'http://localhost:3000/api/files/presigned?type=profile-pictures',
- {
- method: 'POST',
- body: JSON.stringify({
- fileName: 'avatar.png',
- contentType: 'image/png',
- fileSize: 4096,
- }),
- }
- )
-
- const response = await POST(request)
- const data = await response.json()
-
- expect(response.status).toBe(200)
- expect(data.fileInfo.key).toMatch(/^profile-pictures\/.*avatar\.png$/)
- expect(data.fileInfo.path).toMatch(/\/api\/files\/serve\/blob\/.+\?context=profile-pictures$/)
- expect(data.presignedUrl).toBeTruthy()
- expect(data.directUploadSupported).toBe(true)
- })
-
- it('should return error for unknown storage provider', async () => {
- setupFileApiMocks({
- cloudEnabled: true,
- storageProvider: 's3',
- })
-
- storageServiceMockFns.mockGeneratePresignedUploadUrl.mockRejectedValue(
- new Error('Unknown storage provider: unknown')
- )
-
- const request = new NextRequest(
- 'http://localhost:3000/api/files/presigned?type=profile-pictures',
- {
- method: 'POST',
- body: JSON.stringify({
- fileName: 'avatar.png',
- contentType: 'image/png',
- fileSize: 1024,
- }),
- }
- )
-
- const response = await POST(request)
- const data = await response.json()
-
- expect(response.status).toBe(500)
- expect(data.error).toBeTruthy()
- expect(typeof data.error).toBe('string')
- })
-
- it('should handle S3 errors gracefully', async () => {
- setupFileApiMocks({
- cloudEnabled: true,
- storageProvider: 's3',
- })
-
- storageServiceMockFns.mockGeneratePresignedUploadUrl.mockRejectedValue(
- new Error('S3 service unavailable')
- )
-
- const request = new NextRequest(
- 'http://localhost:3000/api/files/presigned?type=profile-pictures',
- {
- method: 'POST',
- body: JSON.stringify({
- fileName: 'avatar.png',
- contentType: 'image/png',
- fileSize: 1024,
- }),
- }
- )
-
- const response = await POST(request)
- const data = await response.json()
-
- expect(response.status).toBe(500)
- expect(data.error).toBeTruthy()
- expect(typeof data.error).toBe('string')
- })
-
- it('should handle Azure Blob errors gracefully', async () => {
- setupFileApiMocks({
- cloudEnabled: true,
- storageProvider: 'blob',
- })
-
- storageServiceMockFns.mockGeneratePresignedUploadUrl.mockRejectedValue(
- new Error('Azure service unavailable')
- )
-
- const request = new NextRequest(
- 'http://localhost:3000/api/files/presigned?type=profile-pictures',
- {
- method: 'POST',
- body: JSON.stringify({
- fileName: 'avatar.png',
- contentType: 'image/png',
- fileSize: 1024,
- }),
- }
- )
-
- const response = await POST(request)
- const data = await response.json()
-
- expect(response.status).toBe(500)
- expect(data.error).toBeTruthy()
- expect(typeof data.error).toBe('string')
- })
-
- it('should handle malformed JSON gracefully', async () => {
- setupFileApiMocks({
- cloudEnabled: true,
- storageProvider: 's3',
- })
-
- const request = new NextRequest('http://localhost:3000/api/files/presigned', {
- method: 'POST',
- body: 'invalid json',
- })
-
- const response = await POST(request)
- const data = await response.json()
-
- expect(response.status).toBe(400) // Changed from 500 to 400 (ValidationError)
- expect(data.error).toBe('Invalid JSON in request body') // Updated error message
- expect(data.code).toBe('VALIDATION_ERROR')
- })
-
- it('rejects the unauthorizable chat context without minting a URL', async () => {
- setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
-
- const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', {
- method: 'POST',
- body: JSON.stringify({
- fileName: 'poc.html',
- contentType: 'text/html',
- fileSize: 41,
- }),
- })
-
- const response = await POST(request)
- const data = await response.json()
-
- expect(response.status).toBe(400)
- expect(data.error).toContain('Invalid type parameter')
- expect(storageServiceMockFns.mockGeneratePresignedUploadUrl).not.toHaveBeenCalled()
- })
- })
-
- describe('mothership uploads', () => {
- it('uses validateAttachmentFileType (not validateFileType) — accepts images', async () => {
- setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
-
- const request = new NextRequest(
- 'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1',
- {
- method: 'POST',
- body: JSON.stringify({
- fileName: 'screenshot.png',
- contentType: 'image/png',
- fileSize: 4096,
- }),
- }
- )
-
- const response = await POST(request)
- expect(response.status).toBe(200)
- expect(mockValidateAttachmentFileType).toHaveBeenCalledWith('screenshot.png', {
- allowArchives: true,
- })
- expect(mockValidateFileType).not.toHaveBeenCalled()
- })
-
- it('rejects unsupported types when validator returns an error', async () => {
- setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
- mockValidateAttachmentFileType.mockReturnValue({
- code: 'UNSUPPORTED_FILE_TYPE',
- message: 'Unsupported file type: exe.',
- supportedTypes: [],
- })
-
- const request = new NextRequest(
- 'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1',
- {
- method: 'POST',
- body: JSON.stringify({
- fileName: 'virus.exe',
- contentType: 'application/octet-stream',
- fileSize: 4096,
- }),
- }
- )
-
- const response = await POST(request)
- const data = await response.json()
- expect(response.status).toBe(400)
- expect(data.code).toBe('VALIDATION_ERROR')
- expect(data.error).toContain('exe')
- })
-
- it('returns 403 when user lacks workspace write permission', async () => {
- setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
- mockGetUserEntityPermissions.mockResolvedValue('read')
-
- const request = new NextRequest(
- 'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1',
- {
- method: 'POST',
- body: JSON.stringify({
- fileName: 'doc.pdf',
- contentType: 'application/pdf',
- fileSize: 4096,
- }),
- }
- )
-
- const response = await POST(request)
- expect(response.status).toBe(403)
- })
-
- it('issues an unbilled pending mothership upload binding', async () => {
- setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
-
- const request = new NextRequest(
- 'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1',
- {
- method: 'POST',
- body: JSON.stringify({
- fileName: 'screenshot.png',
- contentType: 'image/png',
- fileSize: 4096,
- }),
- }
- )
-
- const response = await POST(request)
- const data = await response.json()
-
- expect(response.status).toBe(200)
- expect(mockInsertFileMetadata).toHaveBeenCalledTimes(1)
- expect(mockInsertFileMetadata).toHaveBeenCalledWith({
- key: data.fileInfo.key,
- userId: 'test-user-id',
- workspaceId: 'ws-1',
- context: 'mothership',
- originalName: 'screenshot.png',
- contentType: 'image/png',
- size: 4096,
- })
- expect(mockCheckStorageQuotaForBillingContext).not.toHaveBeenCalled()
- expect(mockResolveStorageBillingContext).not.toHaveBeenCalled()
- expect(mockIncrementStorageUsageForBillingContext).not.toHaveBeenCalled()
- expect(mockDecrementStorageUsageForBillingContext).not.toHaveBeenCalled()
- })
-
- it('returns 500 when insertFileMetadata fails so callers do not get an unauthorizable URL', async () => {
- setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
- mockInsertFileMetadata.mockRejectedValueOnce(new Error('DB connection lost'))
-
- const request = new NextRequest(
- 'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1',
- {
- method: 'POST',
- body: JSON.stringify({
- fileName: 'screenshot.png',
- contentType: 'image/png',
- fileSize: 4096,
- }),
- }
- )
-
- const response = await POST(request)
- expect(response.status).toBe(500)
- })
- })
-
- describe('execution uploads', () => {
- it('uses validateAttachmentFileType — accepts video', async () => {
- setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
-
- const request = new NextRequest(
- 'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1&workflowId=wf-1&executionId=exec-1',
- {
- method: 'POST',
- body: JSON.stringify({
- fileName: 'output.mp4',
- contentType: 'video/mp4',
- fileSize: 4096,
- }),
- }
- )
-
- const response = await POST(request)
- expect(response.status).toBe(200)
- expect(mockValidateAttachmentFileType).toHaveBeenCalledWith('output.mp4')
- expect(mockValidateFileType).not.toHaveBeenCalled()
- })
-
- it('rejects when validator returns an error', async () => {
- setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
- mockValidateAttachmentFileType.mockReturnValue({
- code: 'UNSUPPORTED_FILE_TYPE',
- message: 'Unsupported file type: bin.',
- supportedTypes: [],
- })
-
- const request = new NextRequest(
- 'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1&workflowId=wf-1&executionId=exec-1',
- {
- method: 'POST',
- body: JSON.stringify({
- fileName: 'blob.bin',
- contentType: 'application/octet-stream',
- fileSize: 4096,
- }),
- }
- )
-
- const response = await POST(request)
- const data = await response.json()
- expect(response.status).toBe(400)
- expect(data.code).toBe('VALIDATION_ERROR')
- })
-
- it('returns 400 when missing workflowId/executionId', async () => {
- setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
-
- const request = new NextRequest(
- 'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1',
- {
- method: 'POST',
- body: JSON.stringify({
- fileName: 'output.mp4',
- contentType: 'video/mp4',
- fileSize: 4096,
- }),
- }
- )
-
- const response = await POST(request)
- expect(response.status).toBe(400)
- })
-
- it('inserts a workspaceFiles row with context=execution so previews authorize', async () => {
- setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
-
- const request = new NextRequest(
- 'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1&workflowId=wf-1&executionId=exec-1',
- {
- method: 'POST',
- body: JSON.stringify({
- fileName: 'output.mp4',
- contentType: 'video/mp4',
- fileSize: 4096,
- }),
- }
- )
-
- const response = await POST(request)
- const data = await response.json()
-
- expect(response.status).toBe(200)
- expect(mockInsertFileMetadata).toHaveBeenCalledTimes(1)
- expect(mockInsertFileMetadata).toHaveBeenCalledWith({
- key: data.fileInfo.key,
- userId: 'test-user-id',
- workspaceId: 'ws-1',
- context: 'execution',
- originalName: 'output.mp4',
- contentType: 'video/mp4',
- size: 4096,
- })
- })
- })
-
- describe('workspace-logos uploads', () => {
- it('inserts a workspaceFiles row with context=workspace-logos so logos authorize', async () => {
- setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
-
- const request = new NextRequest(
- 'http://localhost:3000/api/files/presigned?type=workspace-logos&workspaceId=ws-1',
- {
- method: 'POST',
- body: JSON.stringify({
- fileName: 'logo.png',
- contentType: 'image/png',
- fileSize: 4096,
- }),
- }
- )
-
- const response = await POST(request)
- const data = await response.json()
-
- expect(response.status).toBe(200)
- expect(mockInsertFileMetadata).toHaveBeenCalledTimes(1)
- expect(mockInsertFileMetadata).toHaveBeenCalledWith({
- key: data.fileInfo.key,
- userId: 'test-user-id',
- workspaceId: 'ws-1',
- context: 'workspace-logos',
- originalName: 'logo.png',
- contentType: 'image/png',
- size: 4096,
- })
- })
- })
-
- describe('knowledge-base uploads', () => {
- it('uses validateFileType (docs-only), not validateAttachmentFileType', async () => {
- setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
-
- const request = new NextRequest(
- 'http://localhost:3000/api/files/presigned?type=knowledge-base&workspaceId=ws-1',
- {
- method: 'POST',
- body: JSON.stringify({
- fileName: 'doc.pdf',
- contentType: 'application/pdf',
- fileSize: 4096,
- }),
- }
- )
-
- const response = await POST(request)
- expect(response.status).toBe(200)
- expect(mockValidateFileType).toHaveBeenCalledWith('doc.pdf', 'application/pdf')
- expect(mockValidateAttachmentFileType).not.toHaveBeenCalled()
- })
-
- it('requires workspaceId for knowledge-base uploads', async () => {
- setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
-
- const request = new NextRequest(
- 'http://localhost:3000/api/files/presigned?type=knowledge-base',
- {
- method: 'POST',
- body: JSON.stringify({
- fileName: 'doc.pdf',
- contentType: 'application/pdf',
- fileSize: 4096,
- }),
- }
- )
-
- const response = await POST(request)
- expect(response.status).toBe(400)
- })
-
- it('returns 403 when the user lacks write access to the workspace', async () => {
- setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
- mockGetUserEntityPermissions.mockResolvedValue('read')
-
- const request = new NextRequest(
- 'http://localhost:3000/api/files/presigned?type=knowledge-base&workspaceId=ws-1',
- {
- method: 'POST',
- body: JSON.stringify({
- fileName: 'doc.pdf',
- contentType: 'application/pdf',
- fileSize: 4096,
- }),
- }
- )
-
- const response = await POST(request)
- expect(response.status).toBe(403)
- })
- })
-})
diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts
deleted file mode 100644
index 49bec3aab16..00000000000
--- a/apps/sim/app/api/files/presigned/route.ts
+++ /dev/null
@@ -1,335 +0,0 @@
-import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
-import { type NextRequest, NextResponse } from 'next/server'
-import {
- presignedUploadBodyContract,
- presignedUploadTypeSchema,
- presignedUploadTypes,
-} from '@/lib/api/contracts/storage-transfer'
-import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
-import { getSession } from '@/lib/auth'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { CopilotFiles } from '@/lib/uploads'
-import { getServeStoragePrefix } from '@/lib/uploads/config'
-import { generateExecutionFileKey } from '@/lib/uploads/contexts/execution/utils'
-import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager'
-import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
-import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service'
-import { insertFileMetadata, recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata'
-import { isImageFileType } from '@/lib/uploads/utils/file-utils'
-import { validateAttachmentFileType, validateFileType } from '@/lib/uploads/utils/validation'
-import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
-import { createErrorResponse } from '@/app/api/files/utils'
-
-const logger = createLogger('PresignedUploadAPI')
-
-class PresignedUrlError extends Error {
- constructor(
- message: string,
- public code: string,
- public statusCode = 400
- ) {
- super(message)
- this.name = 'PresignedUrlError'
- }
-}
-
-class ValidationError extends PresignedUrlError {
- constructor(message: string) {
- super(message, 'VALIDATION_ERROR', 400)
- }
-}
-
-export const POST = withRouteHandler(async (request: NextRequest) => {
- try {
- const session = await getSession()
- if (!session?.user?.id) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const parsed = await parseRequest(
- presignedUploadBodyContract,
- request,
- {},
- {
- validationErrorResponse: (error) => {
- throw new ValidationError(getValidationErrorMessage(error, 'Invalid request data'))
- },
- invalidJsonResponse: () => {
- throw new ValidationError('Invalid JSON in request body')
- },
- }
- )
- if (!parsed.success) return parsed.response
-
- const { fileName, contentType, fileSize } = parsed.data.body
-
- const uploadTypeParam = request.nextUrl.searchParams.get('type')
- if (!uploadTypeParam) {
- throw new ValidationError('type query parameter is required')
- }
-
- const uploadTypeResult = presignedUploadTypeSchema.safeParse(uploadTypeParam)
- if (!uploadTypeResult.success) {
- throw new ValidationError(
- `Invalid type parameter. Must be one of: ${presignedUploadTypes.join(', ')}`
- )
- }
-
- const uploadType = uploadTypeResult.data
-
- if (uploadType === 'knowledge-base') {
- const fileValidationError = validateFileType(fileName, contentType)
- if (fileValidationError) {
- throw new ValidationError(`${fileValidationError.message}`)
- }
- }
-
- const sessionUserId = session.user.id
-
- if (!hasCloudStorage()) {
- logger.info(
- `Local storage detected - presigned URL not available for ${fileName}, client will use API fallback`
- )
- return NextResponse.json({
- fileName,
- presignedUrl: '', // Empty URL signals fallback to API upload
- fileInfo: {
- path: '',
- key: '',
- name: fileName,
- size: fileSize,
- type: contentType,
- },
- directUploadSupported: false,
- })
- }
-
- logger.info(`Generating ${uploadType} presigned URL for ${fileName}`)
-
- let presignedUrlResponse
-
- if (uploadType === 'copilot') {
- try {
- presignedUrlResponse = await CopilotFiles.generateCopilotUploadUrl({
- fileName,
- contentType,
- fileSize,
- userId: sessionUserId,
- expirationSeconds: 3600,
- })
- } catch (error) {
- throw new ValidationError(getErrorMessage(error, 'Chat validation failed'))
- }
- } else if (uploadType === 'mothership') {
- const workspaceId = request.nextUrl.searchParams.get('workspaceId')
- if (!workspaceId?.trim()) {
- throw new ValidationError('workspaceId query parameter is required for chat uploads')
- }
-
- const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId)
- if (permission !== 'write' && permission !== 'admin') {
- return NextResponse.json(
- { error: 'Write or Admin access required for chat uploads' },
- { status: 403 }
- )
- }
-
- const fileValidationError = validateAttachmentFileType(fileName, { allowArchives: true })
- if (fileValidationError) {
- throw new ValidationError(fileValidationError.message)
- }
-
- const customKey = generateWorkspaceFileKey(workspaceId, fileName)
- presignedUrlResponse = await generatePresignedUploadUrl({
- fileName,
- contentType,
- fileSize,
- context: 'mothership',
- userId: sessionUserId,
- customKey,
- expirationSeconds: 3600,
- metadata: { workspaceId },
- })
-
- await insertFileMetadata({
- key: presignedUrlResponse.key,
- userId: sessionUserId,
- workspaceId,
- context: 'mothership',
- originalName: fileName,
- contentType,
- size: fileSize,
- })
- } else if (uploadType === 'execution') {
- const workflowId = request.nextUrl.searchParams.get('workflowId')
- const executionId = request.nextUrl.searchParams.get('executionId')
- const workspaceId = request.nextUrl.searchParams.get('workspaceId')
- if (!workflowId?.trim() || !executionId?.trim() || !workspaceId?.trim()) {
- throw new ValidationError(
- 'workflowId, executionId, and workspaceId query parameters are required for execution uploads'
- )
- }
-
- const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId)
- if (permission !== 'write' && permission !== 'admin') {
- return NextResponse.json(
- { error: 'Write or Admin access required for execution uploads' },
- { status: 403 }
- )
- }
-
- const fileValidationError = validateAttachmentFileType(fileName)
- if (fileValidationError) {
- throw new ValidationError(fileValidationError.message)
- }
-
- const customKey = generateExecutionFileKey({ workspaceId, workflowId, executionId }, fileName)
- presignedUrlResponse = await generatePresignedUploadUrl({
- fileName,
- contentType,
- fileSize,
- context: 'execution',
- userId: sessionUserId,
- customKey,
- expirationSeconds: 3600,
- metadata: { workspaceId, workflowId, executionId },
- })
-
- await insertFileMetadata({
- key: presignedUrlResponse.key,
- userId: sessionUserId,
- workspaceId,
- context: 'execution',
- originalName: fileName,
- contentType,
- size: fileSize,
- })
- } else if (uploadType === 'workspace-logos') {
- const workspaceId = request.nextUrl.searchParams.get('workspaceId')
- if (!workspaceId?.trim()) {
- throw new ValidationError(
- 'workspaceId query parameter is required for workspace-logos uploads'
- )
- }
-
- const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId)
- if (permission !== 'admin') {
- return NextResponse.json(
- { error: 'Admin access required for workspace logo uploads' },
- { status: 403 }
- )
- }
-
- if (!isImageFileType(contentType)) {
- throw new ValidationError(
- 'Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for workspace logo uploads'
- )
- }
-
- presignedUrlResponse = await generatePresignedUploadUrl({
- fileName,
- contentType,
- fileSize,
- context: 'workspace-logos',
- userId: sessionUserId,
- expirationSeconds: 3600,
- metadata: { workspaceId },
- })
-
- await insertFileMetadata({
- key: presignedUrlResponse.key,
- userId: sessionUserId,
- workspaceId,
- context: 'workspace-logos',
- originalName: fileName,
- contentType,
- size: fileSize,
- })
- } else if (uploadType === 'knowledge-base') {
- const workspaceId = request.nextUrl.searchParams.get('workspaceId')
- if (!workspaceId?.trim()) {
- throw new ValidationError(
- 'workspaceId query parameter is required for knowledge-base uploads'
- )
- }
-
- const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId)
- if (permission !== 'write' && permission !== 'admin') {
- return NextResponse.json(
- { error: 'Write or Admin access required for knowledge-base uploads' },
- { status: 403 }
- )
- }
-
- const customKey = generateKnowledgeBaseFileKey(fileName)
- presignedUrlResponse = await generatePresignedUploadUrl({
- fileName,
- contentType,
- fileSize,
- context: 'knowledge-base',
- userId: sessionUserId,
- customKey,
- expirationSeconds: 3600,
- metadata: { workspaceId },
- })
-
- await recordKnowledgeBaseFileOwnership({
- key: presignedUrlResponse.key,
- userId: sessionUserId,
- workspaceId,
- originalName: fileName,
- contentType,
- size: fileSize,
- })
- } else {
- if (!isImageFileType(contentType)) {
- throw new ValidationError(
- 'Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for profile picture uploads'
- )
- }
-
- presignedUrlResponse = await generatePresignedUploadUrl({
- fileName,
- contentType,
- fileSize,
- context: uploadType,
- userId: sessionUserId,
- expirationSeconds: 3600, // 1 hour
- })
- }
-
- const finalPath = `/api/files/serve/${getServeStoragePrefix()}/${encodeURIComponent(presignedUrlResponse.key)}?context=${uploadType}`
-
- return NextResponse.json({
- fileName,
- presignedUrl: presignedUrlResponse.url,
- fileInfo: {
- path: finalPath,
- key: presignedUrlResponse.key,
- name: fileName,
- size: fileSize,
- type: contentType,
- },
- uploadHeaders: presignedUrlResponse.uploadHeaders,
- directUploadSupported: true,
- })
- } catch (error) {
- logger.error('Error generating presigned URL:', error)
-
- if (error instanceof PresignedUrlError) {
- return NextResponse.json(
- {
- error: error.message,
- code: error.code,
- directUploadSupported: false,
- },
- { status: error.statusCode }
- )
- }
-
- return createErrorResponse(
- error instanceof Error ? error : new Error('Failed to generate presigned URL')
- )
- }
-})
diff --git a/apps/sim/app/api/files/upload/route.test.ts b/apps/sim/app/api/files/upload/route.test.ts
deleted file mode 100644
index 034efb5eb70..00000000000
--- a/apps/sim/app/api/files/upload/route.test.ts
+++ /dev/null
@@ -1,814 +0,0 @@
-/**
- * Tests for file upload API route
- *
- * @vitest-environment node
- */
-import {
- authMockFns,
- hybridAuthMockFns,
- permissionsMock,
- permissionsMockFns,
- storageServiceMock,
- storageServiceMockFns,
-} from '@sim/testing'
-import { NextRequest } from 'next/server'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-
-const mocks = vi.hoisted(() => {
- const mockVerifyFileAccess = vi.fn()
- const mockVerifyWorkspaceFileAccess = vi.fn()
- const mockVerifyKBFileAccess = vi.fn()
- const mockVerifyCopilotFileAccess = vi.fn()
- const mockUploadWorkspaceFile = vi.fn()
- const mockGetStorageProvider = vi.fn()
- const mockIsUsingCloudStorage = vi.fn()
- const mockUploadFile = vi.fn()
- const mockUploadExecutionFile = vi.fn()
- const mockCheckStorageQuota = vi.fn()
- const mockCheckStorageQuotaForBillingContext = vi.fn()
- const mockDecrementStorageUsageForBillingContext = vi.fn()
- const mockIncrementStorageUsageForBillingContext = vi.fn()
- const mockResolveStorageBillingContext = vi.fn()
-
- return {
- mockVerifyFileAccess,
- mockVerifyWorkspaceFileAccess,
- mockVerifyKBFileAccess,
- mockVerifyCopilotFileAccess,
- mockUploadWorkspaceFile,
- mockGetStorageProvider,
- mockIsUsingCloudStorage,
- mockUploadFile,
- mockUploadExecutionFile,
- mockCheckStorageQuota,
- mockCheckStorageQuotaForBillingContext,
- mockDecrementStorageUsageForBillingContext,
- mockIncrementStorageUsageForBillingContext,
- mockResolveStorageBillingContext,
- }
-})
-
-vi.mock('@sim/utils/id', () => ({
- generateId: vi.fn(() => 'test-uuid'),
- generateShortId: vi.fn(() => 'mock-short-id'),
- isValidUuid: vi.fn((v: string) =>
- /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v)
- ),
-}))
-
-vi.mock('@/app/api/files/authorization', () => ({
- verifyFileAccess: mocks.mockVerifyFileAccess,
- verifyWorkspaceFileAccess: mocks.mockVerifyWorkspaceFileAccess,
- verifyKBFileAccess: mocks.mockVerifyKBFileAccess,
- verifyCopilotFileAccess: mocks.mockVerifyCopilotFileAccess,
-}))
-
-vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
-
-vi.mock('@/lib/uploads/contexts/workspace', () => ({
- uploadWorkspaceFile: mocks.mockUploadWorkspaceFile,
-}))
-
-vi.mock('@/lib/uploads/contexts/execution', () => ({
- uploadExecutionFile: mocks.mockUploadExecutionFile,
-}))
-
-vi.mock('@/lib/uploads', () => ({
- getStorageProvider: mocks.mockGetStorageProvider,
- isUsingCloudStorage: mocks.mockIsUsingCloudStorage,
- uploadFile: mocks.mockUploadFile,
-}))
-
-vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
-
-vi.mock('@/lib/billing/storage', () => ({
- checkStorageQuota: mocks.mockCheckStorageQuota,
- checkStorageQuotaForBillingContext: mocks.mockCheckStorageQuotaForBillingContext,
- decrementStorageUsageForBillingContext: mocks.mockDecrementStorageUsageForBillingContext,
- incrementStorageUsageForBillingContext: mocks.mockIncrementStorageUsageForBillingContext,
- resolveStorageBillingContext: mocks.mockResolveStorageBillingContext,
-}))
-
-vi.mock('@/lib/uploads/shared/types', async (importOriginal) => {
- const actual = await importOriginal()
- return {
- ...actual,
- MAX_WORKSPACE_FORMDATA_FILE_SIZE: 1024,
- }
-})
-
-vi.mock('@/lib/uploads/setup.server', () => ({
- UPLOAD_DIR_SERVER: '/tmp/test-uploads',
-}))
-
-import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace'
-import { POST } from '@/app/api/files/upload/route'
-
-/**
- * Configure mocks for authenticated file upload tests
- */
-function setupFileApiMocks(
- options: {
- authenticated?: boolean
- storageProvider?: 's3' | 'blob' | 'local'
- cloudEnabled?: boolean
- } = {}
-) {
- const { authenticated = true, storageProvider = 's3', cloudEnabled = true } = options
-
- vi.stubGlobal('crypto', {
- randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'),
- })
-
- if (authenticated) {
- authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'test-user-id' } })
- } else {
- authMockFns.mockGetSession.mockResolvedValue(null)
- }
-
- hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValue({
- success: authenticated,
- userId: authenticated ? 'test-user-id' : undefined,
- error: authenticated ? undefined : 'Unauthorized',
- })
-
- mocks.mockVerifyFileAccess.mockResolvedValue(true)
- mocks.mockVerifyWorkspaceFileAccess.mockResolvedValue(true)
- mocks.mockVerifyKBFileAccess.mockResolvedValue(true)
- mocks.mockVerifyCopilotFileAccess.mockResolvedValue(true)
-
- permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin')
-
- mocks.mockUploadWorkspaceFile.mockResolvedValue({
- id: 'test-file-id',
- name: 'test.txt',
- url: '/api/files/serve/workspace/test-workspace-id/test-file.txt',
- size: 100,
- type: 'text/plain',
- key: 'workspace/test-workspace-id/1234567890-test.txt',
- uploadedAt: new Date().toISOString(),
- expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
- })
-
- mocks.mockUploadExecutionFile.mockResolvedValue({
- id: 'test-execution-file-id',
- name: 'test.txt',
- url: '/api/files/serve/execution/test-workspace-id/test-file.txt',
- size: 100,
- type: 'text/plain',
- key: 'execution/test-workspace-id/1234567890-test.txt',
- uploadedAt: new Date().toISOString(),
- expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
- })
-
- mocks.mockGetStorageProvider.mockReturnValue(storageProvider)
- mocks.mockIsUsingCloudStorage.mockReturnValue(cloudEnabled)
- mocks.mockUploadFile.mockResolvedValue({
- path: '/api/files/serve/test-key.txt',
- key: 'test-key.txt',
- name: 'test.txt',
- size: 100,
- type: 'text/plain',
- })
-
- storageServiceMockFns.mockHasCloudStorage.mockReturnValue(cloudEnabled)
- storageServiceMockFns.mockUploadFile.mockResolvedValue({
- key: 'test-key',
- path: '/test/path',
- })
-
- mocks.mockCheckStorageQuota.mockResolvedValue({
- allowed: true,
- currentUsage: 0,
- limit: Number.MAX_SAFE_INTEGER,
- })
-}
-
-describe('File Upload API Route', () => {
- const createMockFormData = (files: File[], context = 'workspace'): FormData => {
- const formData = new FormData()
- formData.append('context', context)
- formData.append('workspaceId', 'test-workspace-id')
- files.forEach((file) => {
- formData.append('file', file)
- })
- return formData
- }
-
- const createMockFile = (
- name = 'test.txt',
- type = 'text/plain',
- content = 'test content'
- ): File => {
- return new File([content], name, { type })
- }
-
- const createUploadRequest = (formData: FormData): NextRequest =>
- new NextRequest('http://localhost:3000/api/files/upload', {
- method: 'POST',
- headers: { 'content-length': '1024' },
- body: formData,
- })
-
- beforeEach(() => {
- vi.clearAllMocks()
- })
-
- afterEach(() => {
- vi.clearAllMocks()
- })
-
- it('should upload a file to local storage', async () => {
- setupFileApiMocks({
- cloudEnabled: false,
- storageProvider: 'local',
- })
-
- const mockFile = createMockFile()
- const formData = createMockFormData([mockFile])
-
- const req = createUploadRequest(formData)
-
- const response = await POST(req)
- const data = await response.json()
-
- expect(response.status).toBe(200)
- expect(data).toHaveProperty('url')
- expect(data.url).toMatch(/\/api\/files\/serve\/.*\.txt$/)
- expect(data).toHaveProperty('name', 'test.txt')
- expect(data).toHaveProperty('size')
- expect(data).toHaveProperty('type', 'text/plain')
- expect(data).toHaveProperty('key')
-
- expect(uploadWorkspaceFile).toHaveBeenCalled()
- })
-
- it('should accept chunked multipart uploads without a content-length header', async () => {
- setupFileApiMocks({
- cloudEnabled: false,
- storageProvider: 'local',
- })
-
- const formData = createMockFormData([createMockFile()])
- const req = new NextRequest('http://localhost:3000/api/files/upload', {
- method: 'POST',
- body: formData,
- })
-
- expect(req.headers.get('content-length')).toBeNull()
-
- const response = await POST(req)
-
- expect(response.status).toBe(200)
- expect(uploadWorkspaceFile).toHaveBeenCalled()
- })
-
- it('should upload a file to S3 when in S3 mode', async () => {
- setupFileApiMocks({
- cloudEnabled: true,
- storageProvider: 's3',
- })
-
- const mockFile = createMockFile()
- const formData = createMockFormData([mockFile])
-
- const req = createUploadRequest(formData)
-
- const response = await POST(req)
- const data = await response.json()
-
- expect(response.status).toBe(200)
- expect(data).toHaveProperty('url')
- expect(data.url).toContain('/api/files/serve/')
- expect(data).toHaveProperty('name', 'test.txt')
- expect(data).toHaveProperty('size')
- expect(data).toHaveProperty('type', 'text/plain')
- expect(data).toHaveProperty('key')
-
- expect(uploadWorkspaceFile).toHaveBeenCalled()
- })
-
- it('uploads a direct mothership attachment without workspace storage accounting', async () => {
- setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
-
- const response = await POST(
- createUploadRequest(createMockFormData([createMockFile('attachment.txt')], 'mothership'))
- )
-
- expect(response.status).toBe(200)
- expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalledWith(
- expect.objectContaining({ context: 'mothership' })
- )
- expect(mocks.mockCheckStorageQuotaForBillingContext).not.toHaveBeenCalled()
- expect(mocks.mockResolveStorageBillingContext).not.toHaveBeenCalled()
- expect(mocks.mockIncrementStorageUsageForBillingContext).not.toHaveBeenCalled()
- expect(mocks.mockDecrementStorageUsageForBillingContext).not.toHaveBeenCalled()
- })
-
- it('does not mutate storage counters when a direct mothership upload fails', async () => {
- setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
- storageServiceMockFns.mockUploadFile.mockRejectedValueOnce(new Error('storage unavailable'))
-
- const response = await POST(
- createUploadRequest(createMockFormData([createMockFile('attachment.txt')], 'mothership'))
- )
-
- expect(response.status).toBe(500)
- expect(mocks.mockCheckStorageQuotaForBillingContext).not.toHaveBeenCalled()
- expect(mocks.mockResolveStorageBillingContext).not.toHaveBeenCalled()
- expect(mocks.mockIncrementStorageUsageForBillingContext).not.toHaveBeenCalled()
- expect(mocks.mockDecrementStorageUsageForBillingContext).not.toHaveBeenCalled()
- })
-
- it('should handle multiple file uploads', async () => {
- setupFileApiMocks({
- cloudEnabled: false,
- storageProvider: 'local',
- })
-
- const mockFile1 = createMockFile('file1.txt', 'text/plain')
- const mockFile2 = createMockFile('file2.txt', 'text/plain')
- const formData = createMockFormData([mockFile1, mockFile2])
-
- const req = createUploadRequest(formData)
-
- const response = await POST(req)
- const data = await response.json()
-
- expect(response.status).toBeGreaterThanOrEqual(200)
- expect(response.status).toBeLessThan(600)
- expect(data).toBeDefined()
- })
-
- it('rejects oversized workspace uploads before materializing file contents', async () => {
- setupFileApiMocks({
- cloudEnabled: false,
- storageProvider: 'local',
- })
-
- const mockFile = createMockFile('large.txt', 'text/plain', 'x'.repeat(1025))
- const arrayBufferSpy = vi.spyOn(mockFile, 'arrayBuffer')
- const formData = {
- getAll: (name: string) => (name === 'file' ? [mockFile] : []),
- get: (name: string) => {
- if (name === 'context') return 'workspace'
- if (name === 'workspaceId') return 'test-workspace-id'
- return null
- },
- } as unknown as FormData
-
- const req = {
- formData: async () => formData,
- } as unknown as NextRequest
-
- const response = await POST(req)
- const data = await response.json()
-
- expect(response.status).toBe(413)
- expect(data.error).toBe('PayloadSizeLimitError')
- expect(data.message).toContain('File exceeds the server upload limit')
- expect(data.message).toContain('Use direct upload for larger workspace files')
- expect(arrayBufferSpy).not.toHaveBeenCalled()
- expect(uploadWorkspaceFile).not.toHaveBeenCalled()
- })
-
- it('should handle missing files', async () => {
- setupFileApiMocks()
-
- const formData = new FormData()
-
- const req = createUploadRequest(formData)
-
- const response = await POST(req)
- const data = await response.json()
-
- expect(response.status).toBe(400)
- expect(data).toHaveProperty('error', 'InvalidRequestError')
- expect(data).toHaveProperty('message', 'No files provided')
- })
-
- it('should handle S3 upload errors', async () => {
- setupFileApiMocks({
- cloudEnabled: true,
- storageProvider: 's3',
- })
-
- mocks.mockUploadWorkspaceFile.mockRejectedValue(new Error('Storage limit exceeded'))
-
- const mockFile = createMockFile()
- const formData = createMockFormData([mockFile])
-
- const req = createUploadRequest(formData)
-
- const response = await POST(req)
- const data = await response.json()
-
- expect(response.status).toBe(413)
- expect(data).toHaveProperty('error')
- expect(typeof data.error).toBe('string')
- })
-})
-
-describe('File Upload Security Tests', () => {
- beforeEach(() => {
- vi.clearAllMocks()
-
- authMockFns.mockGetSession.mockResolvedValue({
- user: { id: 'test-user-id' },
- })
-
- storageServiceMockFns.mockHasCloudStorage.mockReturnValue(false)
- storageServiceMockFns.mockUploadFile.mockResolvedValue({
- key: 'test-key',
- path: '/test/path',
- })
- mocks.mockIsUsingCloudStorage.mockReturnValue(false)
- })
-
- afterEach(() => {
- vi.clearAllMocks()
- })
-
- describe('File Extension Validation', () => {
- beforeEach(() => {
- setupFileApiMocks({
- cloudEnabled: false,
- storageProvider: 'local',
- })
- })
-
- it('should accept allowed file types', async () => {
- const allowedTypes = [
- 'pdf',
- 'doc',
- 'docx',
- 'txt',
- 'md',
- 'png',
- 'jpg',
- 'jpeg',
- 'gif',
- 'csv',
- 'xlsx',
- 'xls',
- ]
-
- for (const ext of allowedTypes) {
- const formData = new FormData()
- const file = new File(['test content'], `test.${ext}`, { type: 'application/octet-stream' })
- formData.append('file', file)
- formData.append('context', 'workspace')
- formData.append('workspaceId', 'test-workspace-id')
-
- const req = new Request('http://localhost/api/files/upload', {
- method: 'POST',
- headers: { 'content-length': '1024' },
- body: formData,
- })
-
- const response = await POST(req as unknown as NextRequest)
-
- expect(response.status).toBe(200)
- }
- })
-
- it('should accept HTML files (supported document type)', async () => {
- const formData = new FormData()
- const htmlContent = 'Hello World
'
- const file = new File([htmlContent], 'document.html', { type: 'text/html' })
- formData.append('file', file)
- formData.append('context', 'workspace')
- formData.append('workspaceId', 'test-workspace-id')
-
- const req = new Request('http://localhost/api/files/upload', {
- method: 'POST',
- headers: { 'content-length': '1024' },
- body: formData,
- })
-
- const response = await POST(req as unknown as NextRequest)
-
- expect(response.status).toBe(200)
- })
-
- it('should accept SVG files (supported image type)', async () => {
- const formData = new FormData()
- const svgContent =
- ''
- const file = new File([svgContent], 'image.svg', { type: 'image/svg+xml' })
- formData.append('file', file)
- formData.append('context', 'workspace')
- formData.append('workspaceId', 'test-workspace-id')
-
- const req = new Request('http://localhost/api/files/upload', {
- method: 'POST',
- headers: { 'content-length': '1024' },
- body: formData,
- })
-
- const response = await POST(req as unknown as NextRequest)
-
- expect(response.status).toBe(200)
- })
-
- it('should reject unsupported file types', async () => {
- const formData = new FormData()
- const content = 'binary data'
- const file = new File([content], 'archive.exe', { type: 'application/octet-stream' })
- formData.append('file', file)
- formData.append('context', 'workspace')
- formData.append('workspaceId', 'test-workspace-id')
-
- const req = new Request('http://localhost/api/files/upload', {
- method: 'POST',
- headers: { 'content-length': '1024' },
- body: formData,
- })
-
- const response = await POST(req as unknown as NextRequest)
-
- expect(response.status).toBe(400)
- const data = await response.json()
- expect(data.message).toContain("File type 'exe' is not allowed")
- })
-
- it('should reject files without extensions', async () => {
- const formData = new FormData()
- const file = new File(['test content'], 'noextension', { type: 'application/octet-stream' })
- formData.append('file', file)
- formData.append('context', 'workspace')
- formData.append('workspaceId', 'test-workspace-id')
-
- const req = new Request('http://localhost/api/files/upload', {
- method: 'POST',
- headers: { 'content-length': '1024' },
- body: formData,
- })
-
- const response = await POST(req as unknown as NextRequest)
-
- expect(response.status).toBe(400)
- const data = await response.json()
- expect(data.message).toContain("File type 'noextension' is not allowed")
- })
-
- it('should handle multiple files with mixed valid/invalid types', async () => {
- const formData = new FormData()
-
- const validFile = new File(['valid content'], 'valid.pdf', { type: 'application/pdf' })
- formData.append('file', validFile)
-
- const invalidFile = new File(['binary content'], 'malicious.exe', {
- type: 'application/x-msdownload',
- })
- formData.append('file', invalidFile)
- formData.append('context', 'workspace')
- formData.append('workspaceId', 'test-workspace-id')
-
- const req = new Request('http://localhost/api/files/upload', {
- method: 'POST',
- headers: { 'content-length': '1024' },
- body: formData,
- })
-
- const response = await POST(req as unknown as NextRequest)
-
- expect(response.status).toBe(400)
- const data = await response.json()
- expect(data.message).toContain("File type 'exe' is not allowed")
- })
- })
-
- describe('Execution Context Permission Gate', () => {
- const createExecutionFormData = (
- file: File,
- workspaceId: string | null = 'test-workspace-id'
- ) => {
- const formData = new FormData()
- formData.append('file', file)
- formData.append('context', 'execution')
- formData.append('workflowId', 'test-workflow-id')
- formData.append('executionId', 'test-execution-id')
- if (workspaceId !== null) formData.append('workspaceId', workspaceId)
- return formData
- }
-
- const postExecutionUpload = async (workspaceId: string | null = 'test-workspace-id') => {
- const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' })
- const formData = createExecutionFormData(file, workspaceId)
-
- const req = new Request('http://localhost/api/files/upload', {
- method: 'POST',
- headers: { 'content-length': '1024' },
- body: formData,
- })
-
- return POST(req as unknown as NextRequest)
- }
-
- beforeEach(() => {
- setupFileApiMocks({
- cloudEnabled: false,
- storageProvider: 'local',
- })
- })
-
- it('rejects execution uploads without workspaceId', async () => {
- const response = await postExecutionUpload(null)
-
- expect(response.status).toBe(400)
- const data = await response.json()
- expect(data.message).toContain('workflowId, executionId, and workspaceId')
- expect(mocks.mockUploadExecutionFile).not.toHaveBeenCalled()
- })
-
- it('rejects execution uploads for a read-only workspace member', async () => {
- permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read')
-
- const response = await postExecutionUpload()
-
- expect(response.status).toBe(403)
- const data = await response.json()
- expect(data.error).toBe('Write or Admin access required for execution uploads')
- expect(mocks.mockUploadExecutionFile).not.toHaveBeenCalled()
- })
-
- it('rejects execution uploads for a member with no workspace permission', async () => {
- permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue(null)
-
- const response = await postExecutionUpload()
-
- expect(response.status).toBe(403)
- expect(mocks.mockUploadExecutionFile).not.toHaveBeenCalled()
- })
-
- it('allows execution uploads for a write-permission workspace member', async () => {
- permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
-
- const response = await postExecutionUpload()
-
- expect(response.status).toBe(200)
- expect(mocks.mockUploadExecutionFile).toHaveBeenCalledWith(
- {
- workspaceId: 'test-workspace-id',
- workflowId: 'test-workflow-id',
- executionId: 'test-execution-id',
- },
- expect.anything(),
- 'test.pdf',
- 'application/pdf',
- 'test-user-id'
- )
- })
-
- it('allows execution uploads for an admin-permission workspace member', async () => {
- permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin')
-
- const response = await postExecutionUpload()
-
- expect(response.status).toBe(200)
- expect(mocks.mockUploadExecutionFile).toHaveBeenCalled()
- })
- })
-
- describe('Mothership Context Permission Gate', () => {
- const postMothershipUpload = async (workspaceId: string | null = 'test-workspace-id') => {
- const formData = new FormData()
- const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' })
- formData.append('file', file)
- formData.append('context', 'mothership')
- if (workspaceId !== null) formData.append('workspaceId', workspaceId)
-
- const req = new Request('http://localhost/api/files/upload', {
- method: 'POST',
- headers: { 'content-length': '1024' },
- body: formData,
- })
-
- return POST(req as unknown as NextRequest)
- }
-
- beforeEach(() => {
- setupFileApiMocks({
- cloudEnabled: false,
- storageProvider: 'local',
- })
- })
-
- it('rejects mothership uploads without workspaceId', async () => {
- const response = await postMothershipUpload(null)
-
- expect(response.status).toBe(400)
- const data = await response.json()
- expect(data.message).toContain('workspaceId')
- expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled()
- })
-
- it('rejects mothership uploads for a workspace the caller does not belong to', async () => {
- permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue(null)
-
- const response = await postMothershipUpload()
-
- expect(response.status).toBe(403)
- const data = await response.json()
- expect(data.error).toBe('Write or Admin access required for mothership uploads')
- expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled()
- })
-
- it('rejects mothership uploads for a read-only workspace member', async () => {
- permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read')
-
- const response = await postMothershipUpload()
-
- expect(response.status).toBe(403)
- expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled()
- })
-
- it('rejects mothership uploads over the caller storage quota', async () => {
- permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
- mocks.mockCheckStorageQuota.mockResolvedValue({
- allowed: false,
- currentUsage: 100,
- limit: 100,
- error: 'Storage limit exceeded. Used: 0.00GB, Limit: 0GB',
- })
-
- const response = await postMothershipUpload()
-
- expect(response.status).toBe(413)
- const data = await response.json()
- expect(data.error).toContain('Storage limit exceeded')
- expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled()
- })
-
- it('allows mothership uploads for a write-permission workspace member', async () => {
- permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
-
- const response = await postMothershipUpload()
-
- expect(response.status).toBe(200)
- expect(permissionsMockFns.mockGetUserEntityPermissions).toHaveBeenCalledWith(
- 'test-user-id',
- 'workspace',
- 'test-workspace-id'
- )
- expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalled()
- })
-
- it('allows mothership uploads for an admin-permission workspace member', async () => {
- permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin')
-
- const response = await postMothershipUpload()
-
- expect(response.status).toBe(200)
- expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalled()
- })
-
- it('checks quota once against the combined size of a multi-file batch', async () => {
- permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
-
- const formData = new FormData()
- const fileA = new File(['a'.repeat(10)], 'a.pdf', { type: 'application/pdf' })
- const fileB = new File(['b'.repeat(20)], 'b.pdf', { type: 'application/pdf' })
- formData.append('file', fileA)
- formData.append('file', fileB)
- formData.append('context', 'mothership')
- formData.append('workspaceId', 'test-workspace-id')
-
- const req = new Request('http://localhost/api/files/upload', {
- method: 'POST',
- headers: { 'content-length': '1024' },
- body: formData,
- })
-
- const response = await POST(req as unknown as NextRequest)
-
- expect(response.status).toBe(200)
- expect(mocks.mockCheckStorageQuota).toHaveBeenCalledTimes(1)
- expect(mocks.mockCheckStorageQuota).toHaveBeenCalledWith('test-user-id', 30)
- expect(permissionsMockFns.mockGetUserEntityPermissions).toHaveBeenCalledTimes(1)
- })
- })
-
- describe('Authentication Requirements', () => {
- it('should reject uploads without authentication', async () => {
- authMockFns.mockGetSession.mockResolvedValue(null)
-
- const formData = new FormData()
- const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' })
- formData.append('file', file)
-
- const req = new Request('http://localhost/api/files/upload', {
- method: 'POST',
- headers: { 'content-length': '1024' },
- body: formData,
- })
-
- const response = await POST(req as unknown as NextRequest)
-
- expect(response.status).toBe(401)
- const data = await response.json()
- expect(data.error).toBe('Unauthorized')
- })
- })
-})
diff --git a/apps/sim/app/api/files/upload/route.ts b/apps/sim/app/api/files/upload/route.ts
deleted file mode 100644
index 1c013d19c98..00000000000
--- a/apps/sim/app/api/files/upload/route.ts
+++ /dev/null
@@ -1,488 +0,0 @@
-import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
-import { type NextRequest, NextResponse } from 'next/server'
-import { sanitizeFileName } from '@/executor/constants'
-import '@/lib/uploads/core/setup.server'
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
-import {
- uploadFilesFormFieldsSchema,
- uploadFilesFormFilesSchema,
-} from '@/lib/api/contracts/storage-transfer'
-import { getValidationErrorMessage } from '@/lib/api/server'
-import { getSession } from '@/lib/auth'
-import {
- assertKnownSizeWithinLimit,
- isPayloadSizeLimitError,
- MAX_MULTIPART_OVERHEAD_BYTES,
- readFileToBufferWithLimit,
- readFormDataWithLimit,
-} from '@/lib/core/utils/stream-limits'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { captureServerEvent } from '@/lib/posthog/server'
-import type { StorageContext } from '@/lib/uploads/config'
-import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager'
-import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
-import { MAX_WORKSPACE_FORMDATA_FILE_SIZE } from '@/lib/uploads/shared/types'
-import { isArchiveFileName, isImageFileType, resolveFileType } from '@/lib/uploads/utils/file-utils'
-import {
- SUPPORTED_ATTACHMENT_EXTENSIONS,
- SUPPORTED_IMAGE_EXTENSIONS,
- validateFileType,
-} from '@/lib/uploads/utils/validation'
-import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
-import { createErrorResponse, InvalidRequestError } from '@/app/api/files/utils'
-
-const ALLOWED_EXTENSIONS = new Set(SUPPORTED_ATTACHMENT_EXTENSIONS)
-
-function validateFileExtension(filename: string, context: StorageContext): boolean {
- const extension = filename.split('.').pop()?.toLowerCase()
- if (!extension) return false
- // Archives are only extractable in the mothership copilot flow; every other
- // context keeps rejecting them up front instead of failing downstream.
- if (context === 'mothership' && isArchiveFileName(filename)) return true
- return ALLOWED_EXTENSIONS.has(extension)
-}
-
-export const dynamic = 'force-dynamic'
-
-const logger = createLogger('FilesUploadAPI')
-
-export const POST = withRouteHandler(async (request: NextRequest) => {
- try {
- const session = await getSession()
- if (!session?.user?.id) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const formData = await readFormDataWithLimit(request, {
- maxBytes: MAX_WORKSPACE_FORMDATA_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES,
- label: 'multipart upload body',
- })
-
- const rawFiles = formData.getAll('file')
- const filesResult = uploadFilesFormFilesSchema.safeParse(rawFiles)
- if (!filesResult.success) {
- throw new InvalidRequestError('No files provided')
- }
- const files = filesResult.data
- const totalFileSize = files.reduce((total, file) => total + file.size, 0)
- assertKnownSizeWithinLimit(totalFileSize, MAX_WORKSPACE_FORMDATA_FILE_SIZE, 'uploaded files')
-
- const formFieldsResult = uploadFilesFormFieldsSchema.safeParse({
- workflowId: formData.get('workflowId'),
- executionId: formData.get('executionId'),
- workspaceId: formData.get('workspaceId'),
- context: formData.get('context'),
- })
- if (!formFieldsResult.success) {
- throw new InvalidRequestError(
- getValidationErrorMessage(formFieldsResult.error, 'Invalid upload form data')
- )
- }
- const formFields = formFieldsResult.data
- const { workflowId, executionId, workspaceId, context: contextParam } = formFields
-
- // Context must be explicitly provided
- if (!contextParam) {
- throw new InvalidRequestError(
- 'Upload requires explicit context parameter (knowledge-base, workspace, execution, copilot, chat, profile-pictures, or workspace-logos)'
- )
- }
-
- const context = contextParam as StorageContext
-
- const storageService = await import('@/lib/uploads/core/storage-service')
- const usingCloudStorage = storageService.hasCloudStorage()
- logger.info(`Using storage mode: ${usingCloudStorage ? 'Cloud' : 'Local'} for file upload`)
-
- // Execution context requires a workspace write/admin permission check. Resolve it once per
- // request (not per file) since workspaceId is invariant across all files in the upload.
- let executionUploadContext:
- | { workspaceId: string; workflowId: string; executionId: string }
- | undefined
- if (context === 'execution') {
- if (!workflowId || !executionId || !workspaceId) {
- throw new InvalidRequestError(
- 'Execution context requires workflowId, executionId, and workspaceId parameters'
- )
- }
-
- const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
- if (permission !== 'write' && permission !== 'admin') {
- return NextResponse.json(
- { error: 'Write or Admin access required for execution uploads' },
- { status: 403 }
- )
- }
-
- executionUploadContext = { workspaceId, workflowId, executionId }
- }
-
- // Mothership context requires the same workspace write/admin permission check, plus a
- // storage quota check. Resolve both once per request (not per file) since workspaceId is
- // invariant across all files in the upload and quota must account for the full batch size,
- // not just one file.
- let mothershipWorkspaceId: string | undefined
- if (context === 'mothership') {
- if (!workspaceId) {
- throw new InvalidRequestError('Mothership context requires workspaceId parameter')
- }
-
- const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
- if (permission !== 'write' && permission !== 'admin') {
- return NextResponse.json(
- { error: 'Write or Admin access required for mothership uploads' },
- { status: 403 }
- )
- }
-
- const { checkStorageQuota } = await import('@/lib/billing/storage')
- const quotaCheck = await checkStorageQuota(session.user.id, totalFileSize)
- if (!quotaCheck.allowed) {
- return NextResponse.json(
- { error: quotaCheck.error || 'Storage limit exceeded' },
- { status: 413 }
- )
- }
-
- mothershipWorkspaceId = workspaceId
- }
-
- const uploadResults = []
-
- for (const file of files) {
- const originalName = file.name || 'untitled.md'
-
- if (!validateFileExtension(originalName, context)) {
- const extension = originalName.split('.').pop()?.toLowerCase() || 'unknown'
- throw new InvalidRequestError(
- `File type '${extension}' is not allowed. Allowed types: ${Array.from(ALLOWED_EXTENSIONS).join(', ')}`
- )
- }
-
- const buffer = await readFileToBufferWithLimit(file, {
- maxBytes: MAX_WORKSPACE_FORMDATA_FILE_SIZE,
- label: 'uploaded file',
- })
-
- // Handle execution context
- if (context === 'execution' && executionUploadContext) {
- const { uploadExecutionFile } = await import('@/lib/uploads/contexts/execution')
- const userFile = await uploadExecutionFile(
- executionUploadContext,
- buffer,
- originalName,
- file.type,
- session.user.id
- )
-
- uploadResults.push(userFile)
- continue
- }
-
- // Handle knowledge-base context
- if (context === 'knowledge-base') {
- // Validate file type for knowledge base
- const validationError = validateFileType(originalName, file.type)
- if (validationError) {
- throw new InvalidRequestError(validationError.message)
- }
-
- if (!workspaceId) {
- throw new InvalidRequestError('workspaceId is required for knowledge-base uploads')
- }
-
- const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
- if (permission !== 'write' && permission !== 'admin') {
- return NextResponse.json(
- { error: 'Write or Admin access required for knowledge-base uploads' },
- { status: 403 }
- )
- }
-
- logger.info(`Uploading knowledge-base file: ${originalName}`)
-
- const storageKey = generateKnowledgeBaseFileKey(originalName)
-
- const metadata: Record = {
- originalName: originalName,
- uploadedAt: new Date().toISOString(),
- purpose: 'knowledge-base',
- userId: session.user.id,
- workspaceId,
- }
-
- const fileInfo = await storageService.uploadFile({
- file: buffer,
- fileName: storageKey,
- contentType: file.type,
- context: 'knowledge-base',
- preserveKey: true,
- customKey: storageKey,
- metadata,
- })
-
- const finalPath = usingCloudStorage
- ? `${fileInfo.path}?context=knowledge-base`
- : fileInfo.path
-
- const uploadResult = {
- fileName: originalName,
- presignedUrl: '', // Not used for server-side uploads
- fileInfo: {
- path: finalPath,
- key: fileInfo.key,
- name: originalName,
- size: buffer.length,
- type: file.type,
- },
- directUploadSupported: false,
- }
-
- logger.info(`Successfully uploaded knowledge-base file: ${fileInfo.key}`)
- uploadResults.push(uploadResult)
- continue
- }
-
- // Handle workspace context
- if (context === 'workspace') {
- if (!workspaceId) {
- throw new InvalidRequestError('Workspace context requires workspaceId parameter')
- }
- const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
- if (permission !== 'admin' && permission !== 'write') {
- return NextResponse.json(
- { error: 'Write or Admin access required for workspace uploads' },
- { status: 403 }
- )
- }
-
- try {
- const { uploadWorkspaceFile } = await import('@/lib/uploads/contexts/workspace')
- const userFile = await uploadWorkspaceFile(
- workspaceId,
- session.user.id,
- buffer,
- originalName,
- file.type || 'application/octet-stream'
- )
-
- uploadResults.push(userFile)
- continue
- } catch (workspaceError) {
- const errorMessage = getErrorMessage(workspaceError, 'Upload failed')
- const isDuplicate = errorMessage.includes('already exists')
- const isStorageLimitError =
- errorMessage.includes('Storage limit exceeded') ||
- errorMessage.includes('storage limit')
-
- logger.warn(`Workspace file upload failed: ${errorMessage}`)
-
- let statusCode = 500
- if (isDuplicate) statusCode = 409
- else if (isStorageLimitError) statusCode = 413
-
- return NextResponse.json(
- {
- success: false,
- error: errorMessage,
- isDuplicate,
- },
- { status: statusCode }
- )
- }
- }
-
- // Handle mothership context (chat-scoped uploads to workspace S3)
- if (context === 'mothership' && mothershipWorkspaceId) {
- logger.info(`Uploading mothership file: ${originalName}`)
-
- const storageKey = generateWorkspaceFileKey(mothershipWorkspaceId, originalName)
-
- const metadata: Record = {
- originalName: originalName,
- uploadedAt: new Date().toISOString(),
- purpose: 'mothership',
- userId: session.user.id,
- workspaceId: mothershipWorkspaceId,
- }
-
- const fileInfo = await storageService.uploadFile({
- file: buffer,
- fileName: storageKey,
- contentType: file.type || 'application/octet-stream',
- context: 'mothership',
- preserveKey: true,
- customKey: storageKey,
- metadata,
- })
-
- const finalPath = usingCloudStorage ? `${fileInfo.path}?context=mothership` : fileInfo.path
-
- uploadResults.push({
- fileName: originalName,
- presignedUrl: '',
- fileInfo: {
- path: finalPath,
- key: fileInfo.key,
- name: originalName,
- size: buffer.length,
- type: file.type || 'application/octet-stream',
- },
- directUploadSupported: false,
- })
-
- logger.info(`Successfully uploaded mothership file: ${fileInfo.key}`)
- continue
- }
-
- if (
- context === 'copilot' ||
- context === 'chat' ||
- context === 'profile-pictures' ||
- context === 'workspace-logos'
- ) {
- if (context !== 'copilot') {
- const mimeType = file.type
- const isGenericMime = !mimeType || mimeType === 'application/octet-stream'
- const extension = originalName.split('.').pop()?.toLowerCase() ?? ''
- const extensionIsImage = (SUPPORTED_IMAGE_EXTENSIONS as readonly string[]).includes(
- extension
- )
- const isImage = isGenericMime ? extensionIsImage : isImageFileType(mimeType)
- if (!isImage) {
- throw new InvalidRequestError(`Only image files are allowed for ${context} uploads`)
- }
- }
-
- if (context === 'workspace-logos') {
- if (!workspaceId) {
- throw new InvalidRequestError('workspace-logos context requires workspaceId parameter')
- }
- const permission = await getUserEntityPermissions(
- session.user.id,
- 'workspace',
- workspaceId
- )
- if (permission !== 'admin') {
- return NextResponse.json(
- { error: 'Admin access required for workspace logo uploads' },
- { status: 403 }
- )
- }
- }
-
- if (context === 'chat' && workspaceId) {
- const permission = await getUserEntityPermissions(
- session.user.id,
- 'workspace',
- workspaceId
- )
- if (permission === null) {
- return NextResponse.json(
- { error: 'Insufficient permissions for workspace' },
- { status: 403 }
- )
- }
- }
-
- logger.info(`Uploading ${context} file: ${originalName}`)
-
- const resolvedContentType = resolveFileType({ type: file.type, name: originalName })
-
- const timestamp = Date.now()
- const safeFileName = sanitizeFileName(originalName)
- const storageKey = `${context}/${timestamp}-${safeFileName}`
-
- const metadata: Record = {
- originalName: originalName,
- uploadedAt: new Date().toISOString(),
- purpose: context,
- userId: session.user.id,
- }
-
- if (workspaceId && context === 'chat') {
- metadata.workspaceId = workspaceId
- }
-
- const fileInfo = await storageService.uploadFile({
- file: buffer,
- fileName: storageKey,
- contentType: resolvedContentType,
- context,
- preserveKey: true,
- customKey: storageKey,
- metadata,
- })
-
- const finalPath = usingCloudStorage ? `${fileInfo.path}?context=${context}` : fileInfo.path
-
- const uploadResult = {
- fileName: originalName,
- presignedUrl: '', // Not used for server-side uploads
- fileInfo: {
- path: finalPath,
- key: fileInfo.key,
- name: originalName,
- size: buffer.length,
- type: resolvedContentType,
- },
- directUploadSupported: false,
- }
-
- logger.info(`Successfully uploaded ${context} file: ${fileInfo.key}`)
-
- if (context === 'workspace-logos' && workspaceId) {
- recordAudit({
- workspaceId,
- actorId: session.user.id,
- actorName: session.user.name,
- actorEmail: session.user.email,
- action: AuditAction.FILE_UPLOADED,
- resourceType: AuditResourceType.WORKSPACE,
- resourceId: workspaceId,
- description: `Uploaded workspace logo "${originalName}"`,
- metadata: {
- fileName: originalName,
- fileKey: fileInfo.key,
- fileSize: buffer.length,
- fileType: resolvedContentType,
- },
- request,
- })
-
- captureServerEvent(session.user.id, 'workspace_logo_uploaded', {
- workspace_id: workspaceId,
- file_name: originalName,
- file_size: buffer.length,
- })
- }
-
- uploadResults.push(uploadResult)
- continue
- }
-
- // Unknown context
- throw new InvalidRequestError(
- `Unsupported context: ${context}. Use knowledge-base, workspace, execution, copilot, chat, profile-pictures, or workspace-logos`
- )
- }
-
- if (uploadResults.length === 1) {
- return NextResponse.json(uploadResults[0])
- }
- return NextResponse.json({ files: uploadResults })
- } catch (error) {
- logger.error('Error in file upload:', error)
- if (isPayloadSizeLimitError(error)) {
- return NextResponse.json(
- {
- error: 'PayloadSizeLimitError',
- message: `File exceeds the server upload limit of ${Math.round(error.maxBytes / (1024 * 1024))}MB. Use direct upload for larger workspace files.`,
- },
- { status: 413 }
- )
- }
- return createErrorResponse(error instanceof Error ? error : new Error('File upload failed'))
- }
-})
diff --git a/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts
new file mode 100644
index 00000000000..6ed8d6d1e35
--- /dev/null
+++ b/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts
@@ -0,0 +1,43 @@
+import { type NextRequest, NextResponse } from 'next/server'
+import { completeInternalFileUploadContract } from '@/lib/api/contracts/upload-sessions'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { completeUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service'
+import { finalizeUploadPurpose } from '@/app/api/files/uploads/finalizers'
+import { reauthorizeUploadPurpose } from '@/app/api/files/uploads/purposes'
+import {
+ requireUploadUser,
+ toInternalUploadSession,
+ uploadSessionErrorResponse,
+} from '@/app/api/files/uploads/utils'
+
+interface UploadRouteParams {
+ params: Promise<{ uploadId: string }>
+}
+
+export const POST = withRouteHandler(async (request: NextRequest, context: UploadRouteParams) => {
+ const actor = await requireUploadUser()
+ if (actor instanceof NextResponse) return actor
+ const parsed = await parseRequest(completeInternalFileUploadContract, request, context)
+ if (!parsed.success) return parsed.response
+
+ try {
+ const session = await getOwnedUploadSession({
+ uploadId: parsed.data.params.uploadId,
+ uploadToken: parsed.data.headers['upload-token'],
+ userId: actor.id,
+ })
+ await reauthorizeUploadPurpose(actor.id, session)
+ const completed = await completeUploadSession({
+ session,
+ finalize: (claimed) => finalizeUploadPurpose({ session: claimed, actor, request }),
+ })
+ return NextResponse.json({
+ data: toInternalUploadSession(completed.session, completed.value),
+ })
+ } catch (error) {
+ const classified = uploadSessionErrorResponse(error)
+ if (classified) return classified
+ throw error
+ }
+})
diff --git a/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts
new file mode 100644
index 00000000000..b707f196567
--- /dev/null
+++ b/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts
@@ -0,0 +1,37 @@
+import { type NextRequest, NextResponse } from 'next/server'
+import { createInternalFileUploadPartUrlsContract } from '@/lib/api/contracts/upload-sessions'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { createUploadPartUrls, getOwnedUploadSession } from '@/lib/uploads/upload-session/service'
+import { reauthorizeUploadPurpose } from '@/app/api/files/uploads/purposes'
+import { requireUploadUser, uploadSessionErrorResponse } from '@/app/api/files/uploads/utils'
+
+interface UploadRouteParams {
+ params: Promise<{ uploadId: string }>
+}
+
+export const POST = withRouteHandler(async (request: NextRequest, context: UploadRouteParams) => {
+ const actor = await requireUploadUser()
+ if (actor instanceof NextResponse) return actor
+ const parsed = await parseRequest(createInternalFileUploadPartUrlsContract, request, context)
+ if (!parsed.success) return parsed.response
+
+ try {
+ const session = await getOwnedUploadSession({
+ uploadId: parsed.data.params.uploadId,
+ uploadToken: parsed.data.headers['upload-token'],
+ userId: actor.id,
+ })
+ await reauthorizeUploadPurpose(actor.id, session)
+ const parts = await createUploadPartUrls({
+ session,
+ partNumbers: parsed.data.body.partNumbers,
+ localOrigin: request.nextUrl.origin,
+ })
+ return NextResponse.json({ data: { parts } })
+ } catch (error) {
+ const classified = uploadSessionErrorResponse(error)
+ if (classified) return classified
+ throw error
+ }
+})
diff --git a/apps/sim/app/api/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/route.ts
new file mode 100644
index 00000000000..8887bfbb593
--- /dev/null
+++ b/apps/sim/app/api/files/uploads/[uploadId]/route.ts
@@ -0,0 +1,37 @@
+import { type NextRequest, NextResponse } from 'next/server'
+import { abortInternalFileUploadContract } from '@/lib/api/contracts/upload-sessions'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { abortUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service'
+import { reauthorizeUploadPurpose } from '@/app/api/files/uploads/purposes'
+import {
+ requireUploadUser,
+ toInternalUploadSession,
+ uploadSessionErrorResponse,
+} from '@/app/api/files/uploads/utils'
+
+interface UploadRouteParams {
+ params: Promise<{ uploadId: string }>
+}
+
+export const DELETE = withRouteHandler(async (request: NextRequest, context: UploadRouteParams) => {
+ const actor = await requireUploadUser()
+ if (actor instanceof NextResponse) return actor
+ const parsed = await parseRequest(abortInternalFileUploadContract, request, context)
+ if (!parsed.success) return parsed.response
+
+ try {
+ const session = await getOwnedUploadSession({
+ uploadId: parsed.data.params.uploadId,
+ uploadToken: parsed.data.headers['upload-token'],
+ userId: actor.id,
+ })
+ await reauthorizeUploadPurpose(actor.id, session)
+ const aborted = await abortUploadSession(session)
+ return NextResponse.json({ data: toInternalUploadSession(aborted, null) })
+ } catch (error) {
+ const classified = uploadSessionErrorResponse(error)
+ if (classified) return classified
+ throw error
+ }
+})
diff --git a/apps/sim/app/api/files/uploads/finalizers.test.ts b/apps/sim/app/api/files/uploads/finalizers.test.ts
new file mode 100644
index 00000000000..8f22cb0b353
--- /dev/null
+++ b/apps/sim/app/api/files/uploads/finalizers.test.ts
@@ -0,0 +1,242 @@
+/**
+ * @vitest-environment node
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockInsertReturning,
+ mockSelectLimit,
+ mockRecordAudit,
+ mockCaptureServerEvent,
+ mockGetWorkspaceFile,
+ mockRegisterUploadedWorkspaceFile,
+ mockNotifyWorkspaceFilesChanged,
+} = vi.hoisted(() => ({
+ mockInsertReturning: vi.fn(),
+ mockSelectLimit: vi.fn(),
+ mockRecordAudit: vi.fn(),
+ mockCaptureServerEvent: vi.fn(),
+ mockGetWorkspaceFile: vi.fn(),
+ mockRegisterUploadedWorkspaceFile: vi.fn(),
+ mockNotifyWorkspaceFilesChanged: vi.fn(),
+}))
+
+vi.mock('@sim/db', () => ({
+ db: {
+ insert: vi.fn(() => ({
+ values: vi.fn(() => ({
+ onConflictDoNothing: vi.fn(() => ({ returning: mockInsertReturning })),
+ })),
+ })),
+ select: vi.fn(() => ({
+ from: vi.fn(() => ({
+ where: vi.fn(() => ({
+ orderBy: vi.fn(() => ({ limit: mockSelectLimit })),
+ })),
+ })),
+ })),
+ },
+}))
+
+vi.mock('@sim/audit', () => ({
+ AuditAction: { FILE_UPLOADED: 'file.uploaded' },
+ AuditResourceType: { WORKSPACE: 'workspace' },
+ recordAudit: mockRecordAudit,
+}))
+
+vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent }))
+vi.mock('@/lib/uploads/config', () => ({ getServeStoragePrefix: () => 's3' }))
+vi.mock('@/lib/uploads/upload-session/service', () => ({
+ UploadSessionError: class UploadSessionError extends Error {
+ constructor(
+ readonly code: string,
+ message: string
+ ) {
+ super(message)
+ }
+ },
+}))
+vi.mock('@/lib/uploads/contexts/workspace', () => ({
+ getWorkspaceFile: mockGetWorkspaceFile,
+ registerUploadedWorkspaceFile: mockRegisterUploadedWorkspaceFile,
+}))
+vi.mock('@/lib/realtime/notify', () => ({
+ notifyWorkspaceFilesChanged: mockNotifyWorkspaceFilesChanged,
+}))
+
+import { finalizeUploadPurpose } from '@/app/api/files/uploads/finalizers'
+
+const now = new Date('2026-08-04T12:00:00.000Z')
+const actor = { id: 'user-1', name: 'Ada', email: 'ada@example.com' }
+const metadataRow = {
+ id: 'file-1',
+ key: 'workspace-logos/upload-1-logo.png',
+ userId: actor.id,
+ workspaceId: 'workspace-1',
+ folderId: null,
+ context: 'workspace-logos',
+ chatId: null,
+ messageId: null,
+ originalName: 'logo.png',
+ displayName: 'logo.png',
+ contentType: 'image/png',
+ size: 128,
+ sizeBytes: 128,
+ deletedAt: null,
+ uploadedAt: now,
+ updatedAt: now,
+ contentUpdatedAt: now,
+}
+const uploadSession = {
+ id: 'upload-1',
+ workspaceId: 'workspace-1',
+ userId: actor.id,
+ knowledgeBaseId: null,
+ workflowId: null,
+ executionId: null,
+ purpose: 'workspace_logo' as const,
+ method: 'put' as const,
+ storageContext: 'workspace-logos' as const,
+ storageKey: metadataRow.key,
+ finalKey: metadataRow.key,
+ storageProvider: 's3' as const,
+ providerUploadId: null,
+ providerObjectVersion: null,
+ fileName: 'logo.png',
+ contentType: 'image/png',
+ fileSize: 128,
+ partSize: null,
+ partCount: null,
+ status: 'uploading' as const,
+ metadata: {},
+ uploadToken: 'signed-token',
+ createdAt: now,
+ expiresAt: new Date('2026-08-05T12:00:00.000Z'),
+ completedFileId: null,
+ error: null,
+ completedAt: null,
+ updatedAt: now,
+}
+const workspaceFile = {
+ id: 'wf-1',
+ workspaceId: 'workspace-1',
+ name: 'report.csv',
+ key: 'workspace/workspace-1/upload-1-report.csv',
+ path: '/api/files/serve/s3/workspace%2Fworkspace-1%2Fupload-1-report.csv?context=workspace',
+ size: 128,
+ type: 'text/csv',
+ uploadedBy: actor.id,
+ folderId: null,
+ deletedAt: null,
+ uploadedAt: now,
+ updatedAt: now,
+}
+
+describe('upload purpose finalizers', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('emits workspace-logo side effects only for the metadata insert winner', async () => {
+ mockSelectLimit.mockResolvedValueOnce([]).mockResolvedValueOnce([metadataRow])
+ mockInsertReturning.mockResolvedValueOnce([metadataRow])
+ const request = new NextRequest('http://localhost/api/files/uploads/upload-1/complete')
+
+ const first = await finalizeUploadPurpose({ session: uploadSession, actor, request })
+ const retry = await finalizeUploadPurpose({ session: uploadSession, actor, request })
+
+ expect(first.value).toEqual({
+ path: `/api/files/serve/s3/${encodeURIComponent(metadataRow.key)}?context=workspace-logos`,
+ key: metadataRow.key,
+ name: 'logo.png',
+ size: 128,
+ type: 'image/png',
+ })
+ expect(retry.value).toEqual(first.value)
+ expect(mockRecordAudit).toHaveBeenCalledTimes(1)
+ expect(mockCaptureServerEvent).toHaveBeenCalledTimes(1)
+ })
+
+ it('rejects a storage key already bound to a different owner', async () => {
+ mockSelectLimit.mockResolvedValueOnce([{ ...metadataRow, userId: 'other-user' }])
+
+ await expect(
+ finalizeUploadPurpose({
+ session: uploadSession,
+ actor,
+ request: new NextRequest('http://localhost/api/files/uploads/upload-1/complete'),
+ })
+ ).rejects.toMatchObject({ code: 'conflict' })
+ expect(mockRecordAudit).not.toHaveBeenCalled()
+ expect(mockCaptureServerEvent).not.toHaveBeenCalled()
+ })
+
+ it('rejects a replay after its metadata was archived', async () => {
+ mockSelectLimit.mockResolvedValueOnce([
+ { ...metadataRow, deletedAt: new Date('2026-08-04T13:00:00.000Z') },
+ ])
+
+ await expect(
+ finalizeUploadPurpose({
+ session: uploadSession,
+ actor,
+ request: new NextRequest('http://localhost/api/files/uploads/upload-1/complete'),
+ })
+ ).rejects.toMatchObject({ code: 'conflict' })
+ expect(mockInsertReturning).not.toHaveBeenCalled()
+ expect(mockRecordAudit).not.toHaveBeenCalled()
+ expect(mockCaptureServerEvent).not.toHaveBeenCalled()
+ })
+
+ it('emits workspace-file side effects only for the metadata insert winner', async () => {
+ const workspaceSession = {
+ ...uploadSession,
+ purpose: 'workspace_file' as const,
+ storageContext: 'workspace' as const,
+ storageKey: workspaceFile.key,
+ fileName: workspaceFile.name,
+ contentType: workspaceFile.type,
+ }
+ mockRegisterUploadedWorkspaceFile
+ .mockResolvedValueOnce({ file: { id: workspaceFile.id }, created: true })
+ .mockResolvedValueOnce({ file: { id: workspaceFile.id }, created: false })
+ mockGetWorkspaceFile.mockResolvedValue(workspaceFile)
+ const request = new NextRequest('http://localhost/api/files/uploads/upload-1/complete')
+
+ const first = await finalizeUploadPurpose({ session: workspaceSession, actor, request })
+ const retry = await finalizeUploadPurpose({ session: workspaceSession, actor, request })
+
+ expect(retry.value).toEqual(first.value)
+ expect(mockNotifyWorkspaceFilesChanged).toHaveBeenCalledTimes(1)
+ expect(mockRecordAudit).toHaveBeenCalledTimes(1)
+ expect(mockCaptureServerEvent).toHaveBeenCalledTimes(1)
+ })
+
+ it('rejects a workspace-file replay after its metadata was archived', async () => {
+ const workspaceSession = {
+ ...uploadSession,
+ purpose: 'workspace_file' as const,
+ storageContext: 'workspace' as const,
+ storageKey: workspaceFile.key,
+ fileName: workspaceFile.name,
+ contentType: workspaceFile.type,
+ }
+ mockRegisterUploadedWorkspaceFile.mockResolvedValueOnce({
+ file: { id: workspaceFile.id },
+ created: false,
+ })
+ mockGetWorkspaceFile.mockResolvedValueOnce({ ...workspaceFile, deletedAt: now })
+
+ await expect(
+ finalizeUploadPurpose({
+ session: workspaceSession,
+ actor,
+ request: new NextRequest('http://localhost/api/files/uploads/upload-1/complete'),
+ })
+ ).rejects.toMatchObject({ code: 'conflict' })
+ expect(mockNotifyWorkspaceFilesChanged).not.toHaveBeenCalled()
+ expect(mockRecordAudit).not.toHaveBeenCalled()
+ expect(mockCaptureServerEvent).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/files/uploads/finalizers.ts b/apps/sim/app/api/files/uploads/finalizers.ts
new file mode 100644
index 00000000000..6673b9a60d4
--- /dev/null
+++ b/apps/sim/app/api/files/uploads/finalizers.ts
@@ -0,0 +1,362 @@
+import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
+import { db } from '@sim/db'
+import { workspaceFiles } from '@sim/db/schema'
+import { generateId } from '@sim/utils/id'
+import { eq, sql } from 'drizzle-orm'
+import type { NextRequest } from 'next/server'
+import type { V2File } from '@/lib/api/contracts/v2/files'
+import { captureServerEvent } from '@/lib/posthog/server'
+import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify'
+import { getServeStoragePrefix } from '@/lib/uploads/config'
+import {
+ getWorkspaceFile,
+ registerUploadedWorkspaceFile,
+ type WorkspaceFileRecord,
+} from '@/lib/uploads/contexts/workspace'
+import { type StorageContext, toLegacyWorkspaceFileSize } from '@/lib/uploads/shared/types'
+import { UploadSessionError, type UploadSessionRecord } from '@/lib/uploads/upload-session/service'
+import { toV2File } from '@/app/api/v2/files/utils'
+
+export interface UploadActor {
+ id: string
+ name?: string | null
+ email?: string | null
+}
+
+export interface StoredUploadResult {
+ path: string
+ key: string
+ name: string
+ size: number
+ type: string
+}
+
+export interface ExecutionUploadResult {
+ id: string
+ name: string
+ url: string
+ size: number
+ type: string
+ key: string
+ context: 'execution'
+}
+
+export type UploadPurposeResult = V2File | StoredUploadResult | ExecutionUploadResult
+
+interface FinalizedWorkspaceFile {
+ file: WorkspaceFileRecord
+ created: boolean
+}
+
+interface FinalizeUploadPurposeParams {
+ session: UploadSessionRecord
+ actor: UploadActor
+ request: NextRequest
+}
+
+interface FinalizedUploadPurpose {
+ value: UploadPurposeResult
+ completedFileId?: string
+}
+
+interface FinalizedMetadataInput {
+ key: string
+ userId: string
+ workspaceId: string
+ context: StorageContext
+ originalName: string
+ contentType: string
+ size: number
+}
+
+type FileMetadataRecord = typeof workspaceFiles.$inferSelect
+
+/**
+ * Finalizes the domain resource represented by a verified upload object.
+ * Metadata-backed purposes use the storage key as their idempotency identity.
+ */
+export async function finalizeUploadPurpose({
+ session,
+ actor,
+ request,
+}: FinalizeUploadPurposeParams): Promise {
+ switch (session.purpose) {
+ case 'workspace_file':
+ return finalizeInternalWorkspaceFile(session, actor, request)
+ case 'profile_picture':
+ return { value: storedAssetResult(session, 'profile-pictures') }
+ case 'workspace_logo':
+ return finalizeWorkspaceLogo(session, actor, request)
+ case 'mothership_attachment':
+ return finalizeMothershipAttachment(session)
+ case 'execution_attachment':
+ return finalizeExecutionAttachment(session)
+ case 'table_import':
+ case 'knowledge_document':
+ throw new UploadSessionError(
+ 'validation',
+ `Purpose ${session.purpose} is not finalized by the internal files route`
+ )
+ }
+}
+
+async function finalizeInternalWorkspaceFile(
+ session: UploadSessionRecord,
+ actor: UploadActor,
+ request: NextRequest
+): Promise {
+ const finalized = await finalizeWorkspaceFileUpload({ session, actor, request, source: 'ui' })
+ return {
+ value: toV2File(finalized.file),
+ completedFileId: finalized.file.id,
+ }
+}
+
+/**
+ * Registers a verified workspace object and emits its one-time domain side effects.
+ * The metadata insert winner is the only caller that notifies, audits, or records analytics.
+ */
+export async function finalizeWorkspaceFileUpload(params: {
+ session: UploadSessionRecord
+ actor: UploadActor
+ request: NextRequest
+ source: 'api' | 'ui'
+}): Promise {
+ const { session, actor, request, source } = params
+ const workspaceId = requireWorkspaceId(session)
+ const metadata = session.metadata as { folderId?: string | null }
+ const registered = await registerUploadedWorkspaceFile({
+ workspaceId,
+ userId: session.userId,
+ key: session.storageKey,
+ originalName: session.fileName,
+ contentType: session.contentType,
+ folderId: metadata.folderId,
+ })
+ const file = await getWorkspaceFile(workspaceId, registered.file.id, {
+ includeDeleted: true,
+ throwOnError: true,
+ })
+ if (!file) {
+ throw new Error(`Completed workspace file ${registered.file.id} not found`)
+ }
+ if (file.deletedAt) {
+ throw new UploadSessionError('conflict', 'Upload result was deleted')
+ }
+ if (registered.created) {
+ await notifyWorkspaceFilesChanged(workspaceId)
+ captureServerEvent(
+ actor.id,
+ 'file_uploaded',
+ { workspace_id: workspaceId, file_type: session.contentType },
+ { groups: { workspace: workspaceId } }
+ )
+ recordAudit({
+ workspaceId,
+ actorId: actor.id,
+ actorName: actor.name,
+ actorEmail: actor.email,
+ action: AuditAction.FILE_UPLOADED,
+ resourceType: AuditResourceType.FILE,
+ resourceId: file.id,
+ resourceName: file.name,
+ description: `Uploaded file "${file.name}"${source === 'api' ? ' via API' : ''}`,
+ metadata: { fileSize: file.size, fileType: file.type },
+ request,
+ })
+ }
+ return { file, created: registered.created }
+}
+
+async function finalizeWorkspaceLogo(
+ session: UploadSessionRecord,
+ actor: UploadActor,
+ request: NextRequest
+): Promise {
+ const workspaceId = requireWorkspaceId(session)
+ const finalized = await insertOrLoadFileMetadata({
+ key: session.storageKey,
+ userId: session.userId,
+ workspaceId,
+ context: 'workspace-logos',
+ originalName: session.fileName,
+ contentType: session.contentType,
+ size: session.fileSize,
+ })
+
+ if (finalized.created) {
+ recordAudit({
+ workspaceId,
+ actorId: actor.id,
+ actorName: actor.name,
+ actorEmail: actor.email,
+ action: AuditAction.FILE_UPLOADED,
+ resourceType: AuditResourceType.WORKSPACE,
+ resourceId: workspaceId,
+ description: `Uploaded workspace logo "${session.fileName}"`,
+ metadata: {
+ fileName: session.fileName,
+ fileKey: session.storageKey,
+ fileSize: session.fileSize,
+ fileType: session.contentType,
+ },
+ request,
+ })
+ captureServerEvent(actor.id, 'workspace_logo_uploaded', {
+ workspace_id: workspaceId,
+ file_name: session.fileName,
+ file_size: session.fileSize,
+ })
+ }
+
+ return { value: storedAssetResult(session, 'workspace-logos') }
+}
+
+async function finalizeMothershipAttachment(
+ session: UploadSessionRecord
+): Promise {
+ const workspaceId = requireWorkspaceId(session)
+ await insertOrLoadFileMetadata({
+ key: session.storageKey,
+ userId: session.userId,
+ workspaceId,
+ context: 'mothership',
+ originalName: session.fileName,
+ contentType: session.contentType,
+ size: session.fileSize,
+ })
+ return {
+ value: storedAssetResult(session, 'mothership'),
+ }
+}
+
+async function finalizeExecutionAttachment(
+ session: UploadSessionRecord
+): Promise {
+ const workspaceId = requireWorkspaceId(session)
+ const finalized = await insertOrLoadFileMetadata({
+ key: session.storageKey,
+ userId: session.userId,
+ workspaceId,
+ context: 'execution',
+ originalName: session.fileName,
+ contentType: session.contentType,
+ size: session.fileSize,
+ })
+ return {
+ value: {
+ id: finalized.file.id,
+ name: session.fileName,
+ url: servePath(session.storageKey, 'execution'),
+ size: session.fileSize,
+ type: session.contentType,
+ key: session.storageKey,
+ context: 'execution',
+ },
+ completedFileId: finalized.file.id,
+ }
+}
+
+async function insertOrLoadFileMetadata(
+ input: FinalizedMetadataInput
+): Promise<{ file: FileMetadataRecord; created: boolean }> {
+ const existing = await findFileMetadataByKey(input.key)
+ if (existing) {
+ assertMatchingMetadata(existing, input)
+ assertActiveFileMetadata(existing)
+ return { file: existing, created: false }
+ }
+
+ const now = new Date()
+ const [inserted] = await db
+ .insert(workspaceFiles)
+ .values({
+ id: generateId(),
+ key: input.key,
+ userId: input.userId,
+ workspaceId: input.workspaceId,
+ context: input.context,
+ originalName: input.originalName,
+ displayName: input.originalName,
+ contentType: input.contentType,
+ size: toLegacyWorkspaceFileSize(input.size),
+ sizeBytes: input.size,
+ deletedAt: null,
+ uploadedAt: now,
+ updatedAt: now,
+ contentUpdatedAt: now,
+ })
+ .onConflictDoNothing()
+ .returning()
+
+ if (inserted) return { file: inserted, created: true }
+
+ const raceWinner = await findFileMetadataByKey(input.key)
+ if (!raceWinner) {
+ throw new UploadSessionError('conflict', `Storage key ${input.key} could not be registered`)
+ }
+ assertMatchingMetadata(raceWinner, input)
+ assertActiveFileMetadata(raceWinner)
+ return { file: raceWinner, created: false }
+}
+
+async function findFileMetadataByKey(key: string): Promise {
+ const [file] = await db
+ .select()
+ .from(workspaceFiles)
+ .where(eq(workspaceFiles.key, key))
+ .orderBy(sql`${workspaceFiles.deletedAt} IS NULL DESC`)
+ .limit(1)
+ return file
+}
+
+function assertMatchingMetadata(existing: FileMetadataRecord, input: FinalizedMetadataInput): void {
+ const existingSize = existing.sizeBytes ?? existing.size
+ if (
+ existing.key !== input.key ||
+ existing.userId !== input.userId ||
+ existing.workspaceId !== input.workspaceId ||
+ existing.context !== input.context ||
+ existing.originalName !== input.originalName ||
+ existing.contentType !== input.contentType ||
+ existingSize !== input.size
+ ) {
+ throw new UploadSessionError(
+ 'conflict',
+ `Storage key ${input.key} belongs to a different upload`
+ )
+ }
+}
+
+function assertActiveFileMetadata(file: FileMetadataRecord): void {
+ if (file.deletedAt) {
+ throw new UploadSessionError('conflict', 'Upload result was deleted')
+ }
+}
+
+function servePath(key: string, context: StorageContext): string {
+ return `/api/files/serve/${getServeStoragePrefix()}/${encodeURIComponent(key)}?context=${context}`
+}
+
+function storedAssetResult(
+ session: UploadSessionRecord,
+ context: 'profile-pictures' | 'workspace-logos' | 'mothership'
+): StoredUploadResult {
+ return {
+ path: servePath(session.storageKey, context),
+ key: session.storageKey,
+ name: session.fileName,
+ size: session.fileSize,
+ type: session.contentType,
+ }
+}
+
+function requireWorkspaceId(session: UploadSessionRecord): string {
+ if (!session.workspaceId) {
+ throw new UploadSessionError(
+ 'forbidden',
+ `Upload session ${session.id} is missing its workspace scope`
+ )
+ }
+ return session.workspaceId
+}
diff --git a/apps/sim/app/api/files/uploads/purposes.ts b/apps/sim/app/api/files/uploads/purposes.ts
new file mode 100644
index 00000000000..a67d2e3298c
--- /dev/null
+++ b/apps/sim/app/api/files/uploads/purposes.ts
@@ -0,0 +1,188 @@
+import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
+import type { CreateInternalFileUploadBody } from '@/lib/api/contracts/upload-sessions'
+import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace'
+import {
+ createUploadSession,
+ UploadSessionError,
+ type UploadSessionRecord,
+} from '@/lib/uploads/upload-session/service'
+import { isImageFileType } from '@/lib/uploads/utils/file-utils'
+import { validateAttachmentFileType } from '@/lib/uploads/utils/validation'
+import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
+
+export type InternalUploadPurpose = CreateInternalFileUploadBody['purpose']
+
+const INTERNAL_UPLOAD_PURPOSES = new Set([
+ 'workspace_file',
+ 'profile_picture',
+ 'workspace_logo',
+ 'mothership_attachment',
+ 'execution_attachment',
+])
+
+export async function createPurposeUploadSession(
+ userId: string,
+ body: CreateInternalFileUploadBody,
+ localOrigin: string
+) {
+ validatePurposeFile(body)
+
+ switch (body.purpose) {
+ case 'workspace_file': {
+ await requireWorkspacePermission(userId, body.workspaceId, 'write')
+ const folderId = await assertWorkspaceFileFolderTarget(body.workspaceId, body.folderId)
+ return createUploadSession({
+ purpose: body.purpose,
+ workspaceId: body.workspaceId,
+ userId,
+ fileName: body.name,
+ contentType: body.contentType,
+ fileSize: body.size,
+ metadata: { folderId },
+ localOrigin,
+ })
+ }
+ case 'profile_picture':
+ return createUploadSession({
+ purpose: body.purpose,
+ userId,
+ fileName: body.name,
+ contentType: body.contentType,
+ fileSize: body.size,
+ localOrigin,
+ })
+ case 'workspace_logo':
+ await requireWorkspacePermission(userId, body.workspaceId, 'admin')
+ return createUploadSession({
+ purpose: body.purpose,
+ workspaceId: body.workspaceId,
+ userId,
+ fileName: body.name,
+ contentType: body.contentType,
+ fileSize: body.size,
+ localOrigin,
+ })
+ case 'mothership_attachment':
+ await requireWorkspacePermission(userId, body.workspaceId, 'write')
+ return createUploadSession({
+ purpose: body.purpose,
+ workspaceId: body.workspaceId,
+ userId,
+ fileName: body.name,
+ contentType: body.contentType,
+ fileSize: body.size,
+ localOrigin,
+ })
+ case 'execution_attachment':
+ await requireExecutionPermission(userId, body.workflowId, body.workspaceId)
+ return createUploadSession({
+ purpose: body.purpose,
+ workspaceId: body.workspaceId,
+ workflowId: body.workflowId,
+ executionId: body.executionId,
+ userId,
+ fileName: body.name,
+ contentType: body.contentType,
+ fileSize: body.size,
+ localOrigin,
+ })
+ }
+}
+
+/**
+ * Rechecks current domain authorization for every control-plane session request.
+ */
+export async function reauthorizeUploadPurpose(
+ userId: string,
+ session: UploadSessionRecord
+): Promise {
+ if (session.userId !== userId || !isInternalUploadPurpose(session.purpose)) {
+ throw new UploadSessionError('not_found', 'Upload session not found')
+ }
+
+ switch (session.purpose) {
+ case 'workspace_file':
+ case 'mothership_attachment':
+ await requireWorkspacePermission(userId, requireSessionScope(session.workspaceId), 'write')
+ return
+ case 'profile_picture':
+ return
+ case 'workspace_logo':
+ await requireWorkspacePermission(userId, requireSessionScope(session.workspaceId), 'admin')
+ return
+ case 'execution_attachment':
+ await requireExecutionPermission(
+ userId,
+ requireSessionScope(session.workflowId),
+ requireSessionScope(session.workspaceId)
+ )
+ return
+ }
+}
+
+export function isInternalUploadPurpose(purpose: string): purpose is InternalUploadPurpose {
+ return INTERNAL_UPLOAD_PURPOSES.has(purpose as InternalUploadPurpose)
+}
+
+function validatePurposeFile(body: CreateInternalFileUploadBody): void {
+ if (body.purpose === 'profile_picture' || body.purpose === 'workspace_logo') {
+ if (!isImageFileType(body.contentType)) {
+ throw new UploadSessionError(
+ 'validation',
+ `Only image files are allowed for ${body.purpose.replace('_', ' ')} uploads`
+ )
+ }
+ return
+ }
+
+ if (body.purpose === 'mothership_attachment' || body.purpose === 'execution_attachment') {
+ const validation = validateAttachmentFileType(body.name, {
+ allowArchives: body.purpose === 'mothership_attachment',
+ })
+ if (validation) throw new UploadSessionError('validation', validation.message)
+ }
+}
+
+async function requireWorkspacePermission(
+ userId: string,
+ workspaceId: string,
+ action: 'write' | 'admin'
+): Promise {
+ const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
+ const allowed =
+ action === 'admin' ? permission === 'admin' : permission === 'write' || permission === 'admin'
+ if (!allowed) {
+ throw new UploadSessionError(
+ 'forbidden',
+ action === 'admin' ? 'Admin access required' : 'Write or Admin access required'
+ )
+ }
+}
+
+async function requireExecutionPermission(
+ userId: string,
+ workflowId: string,
+ signedWorkspaceId: string
+): Promise {
+ const authorization = await authorizeWorkflowByWorkspacePermission({
+ workflowId,
+ userId,
+ action: 'write',
+ })
+ if (!authorization.workflow) {
+ throw new UploadSessionError('not_found', 'Workflow not found')
+ }
+ if (!authorization.allowed) {
+ throw new UploadSessionError('forbidden', authorization.message ?? 'Workflow access denied')
+ }
+ if (authorization.workflow.workspaceId !== signedWorkspaceId) {
+ throw new UploadSessionError('forbidden', 'Workflow does not belong to the upload workspace')
+ }
+}
+
+function requireSessionScope(value: string | null, label = 'scope'): string {
+ if (!value) {
+ throw new UploadSessionError('forbidden', `Upload session is missing its ${label}`)
+ }
+ return value
+}
diff --git a/apps/sim/app/api/files/uploads/route.test.ts b/apps/sim/app/api/files/uploads/route.test.ts
new file mode 100644
index 00000000000..4ea8c61428a
--- /dev/null
+++ b/apps/sim/app/api/files/uploads/route.test.ts
@@ -0,0 +1,307 @@
+/**
+ * @vitest-environment node
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockGetSession,
+ mockCreateUploadSession,
+ mockGetOwnedUploadSession,
+ mockCompleteUploadSession,
+ mockGetUserEntityPermissions,
+ mockAuthorizeWorkflow,
+} = vi.hoisted(() => ({
+ mockGetSession: vi.fn(),
+ mockCreateUploadSession: vi.fn(),
+ mockGetOwnedUploadSession: vi.fn(),
+ mockCompleteUploadSession: vi.fn(),
+ mockGetUserEntityPermissions: vi.fn(),
+ mockAuthorizeWorkflow: vi.fn(),
+}))
+
+vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
+
+vi.mock('@/lib/uploads/upload-session/service', () => ({
+ UploadSessionError: class UploadSessionError extends Error {
+ constructor(
+ readonly code: string,
+ message: string
+ ) {
+ super(message)
+ }
+ },
+ createUploadSession: mockCreateUploadSession,
+ getOwnedUploadSession: mockGetOwnedUploadSession,
+ completeUploadSession: mockCompleteUploadSession,
+ createUploadPartUrls: vi.fn(),
+ abortUploadSession: vi.fn(),
+}))
+
+vi.mock('@/lib/workspaces/permissions/utils', () => ({
+ getUserEntityPermissions: mockGetUserEntityPermissions,
+}))
+
+vi.mock('@sim/platform-authz/workflow', () => ({
+ authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflow,
+}))
+
+vi.mock('@/lib/uploads/contexts/workspace', () => ({
+ assertWorkspaceFileFolderTarget: vi.fn(),
+ getWorkspaceFile: vi.fn(),
+ registerUploadedWorkspaceFile: vi.fn(),
+}))
+
+vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: vi.fn() }))
+
+import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types'
+import { POST as completeUpload } from '@/app/api/files/uploads/[uploadId]/complete/route'
+import { POST as createUpload } from '@/app/api/files/uploads/route'
+
+const actor = { id: 'user-1', name: 'Ada', email: 'ada@example.com' }
+const now = new Date('2026-08-04T12:00:00.000Z')
+
+function session(overrides: Record = {}) {
+ return {
+ id: 'upload-1',
+ workspaceId: null,
+ userId: actor.id,
+ knowledgeBaseId: null,
+ workflowId: null,
+ executionId: null,
+ purpose: 'profile_picture',
+ method: 'put',
+ storageContext: 'profile-pictures',
+ storageKey: 'profile-pictures/upload-1-avatar.png',
+ finalKey: 'profile-pictures/upload-1-avatar.png',
+ storageProvider: 's3',
+ providerUploadId: null,
+ providerObjectVersion: null,
+ fileName: 'avatar.png',
+ contentType: 'image/png',
+ fileSize: 128,
+ partSize: null,
+ partCount: null,
+ status: 'uploading',
+ metadata: {},
+ uploadToken: 'signed-token',
+ createdAt: now,
+ expiresAt: new Date('2026-08-05T12:00:00.000Z'),
+ completedFileId: null,
+ error: null,
+ completedAt: null,
+ updatedAt: now,
+ ...overrides,
+ }
+}
+
+describe('/api/files/uploads', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetSession.mockResolvedValue({ user: actor })
+ mockGetUserEntityPermissions.mockResolvedValue('admin')
+ })
+
+ it('creates a purpose-scoped PUT session without exposing write capability in the session', async () => {
+ mockCreateUploadSession.mockResolvedValue({
+ ...session(),
+ transfer: {
+ method: 'put',
+ url: 'https://storage.example.com/upload',
+ headers: { 'Content-Type': 'image/png' },
+ },
+ })
+ const request = new NextRequest('http://localhost/api/files/uploads', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ purpose: 'profile_picture',
+ name: 'avatar.png',
+ contentType: 'image/png',
+ size: 128,
+ }),
+ })
+
+ const response = await createUpload(request)
+ const body = await response.json()
+
+ expect(response.status).toBe(201)
+ expect(mockCreateUploadSession).toHaveBeenCalledWith(
+ expect.objectContaining({
+ purpose: 'profile_picture',
+ userId: actor.id,
+ localOrigin: 'http://localhost',
+ })
+ )
+ expect(body.data).toMatchObject({
+ session: {
+ id: 'upload-1',
+ purpose: 'profile_picture',
+ status: 'uploading',
+ result: null,
+ },
+ uploadToken: 'signed-token',
+ transfer: { method: 'put' },
+ })
+ expect(body.data.session).not.toHaveProperty('uploadToken')
+ expect(body.data.session).not.toHaveProperty('transfer')
+ })
+
+ it('creates a PUT session for an empty workspace file', async () => {
+ mockCreateUploadSession.mockResolvedValue({
+ ...session({
+ workspaceId: 'workspace-1',
+ purpose: 'workspace_file',
+ storageContext: 'workspace',
+ storageKey: 'workspace/workspace-1/empty.md',
+ fileName: 'empty.md',
+ contentType: 'text/markdown',
+ fileSize: 0,
+ }),
+ transfer: {
+ method: 'put',
+ url: 'https://storage.example.com/upload',
+ headers: { 'Content-Type': 'text/markdown' },
+ },
+ })
+ const request = new NextRequest('http://localhost/api/files/uploads', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ purpose: 'workspace_file',
+ workspaceId: 'workspace-1',
+ name: 'empty.md',
+ contentType: 'text/markdown',
+ size: 0,
+ }),
+ })
+
+ const response = await createUpload(request)
+
+ expect(response.status).toBe(201)
+ expect(mockCreateUploadSession).toHaveBeenCalledWith(
+ expect.objectContaining({ purpose: 'workspace_file', fileSize: 0 })
+ )
+ await expect(response.json()).resolves.toMatchObject({
+ data: { session: { purpose: 'workspace_file', size: 0 } },
+ })
+ })
+
+ it('preserves the 5 GiB direct-to-storage limit for mothership attachments', async () => {
+ mockCreateUploadSession.mockResolvedValue({
+ ...session({
+ workspaceId: 'workspace-1',
+ purpose: 'mothership_attachment',
+ method: 'multipart',
+ storageContext: 'mothership',
+ storageKey: 'mothership/workspace-1/archive.zip',
+ fileName: 'archive.zip',
+ contentType: 'application/zip',
+ fileSize: MAX_WORKSPACE_FILE_SIZE,
+ }),
+ transfer: { method: 'multipart', partSize: 8 * 1024 * 1024, partCount: 640 },
+ })
+ const request = new NextRequest('http://localhost/api/files/uploads', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ purpose: 'mothership_attachment',
+ workspaceId: 'workspace-1',
+ name: 'archive.zip',
+ contentType: 'application/zip',
+ size: MAX_WORKSPACE_FILE_SIZE,
+ }),
+ })
+
+ const response = await createUpload(request)
+
+ expect(response.status).toBe(201)
+ expect(mockCreateUploadSession).toHaveBeenCalledWith(
+ expect.objectContaining({
+ purpose: 'mothership_attachment',
+ fileSize: MAX_WORKSPACE_FILE_SIZE,
+ })
+ )
+ })
+
+ it('rejects mothership attachments above the 5 GiB direct-to-storage limit', async () => {
+ const request = new NextRequest('http://localhost/api/files/uploads', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ purpose: 'mothership_attachment',
+ workspaceId: 'workspace-1',
+ name: 'archive.zip',
+ contentType: 'application/zip',
+ size: MAX_WORKSPACE_FILE_SIZE + 1,
+ }),
+ })
+
+ const response = await createUpload(request)
+
+ expect(response.status).toBe(400)
+ expect(mockCreateUploadSession).not.toHaveBeenCalled()
+ })
+
+ it('reauthorizes a terminal request and returns only the terminal-safe session', async () => {
+ const logoSession = session({
+ workspaceId: 'workspace-1',
+ purpose: 'workspace_logo',
+ storageContext: 'workspace-logos',
+ storageKey: 'workspace-logos/upload-1-logo.png',
+ fileName: 'logo.png',
+ })
+ const result = {
+ path: '/api/files/serve/s3/workspace-logos%2Fupload-1-logo.png?context=workspace-logos',
+ key: 'workspace-logos/upload-1-logo.png',
+ name: 'logo.png',
+ size: 128,
+ type: 'image/png',
+ }
+ mockGetOwnedUploadSession.mockReturnValue(logoSession)
+ mockCompleteUploadSession.mockResolvedValue({
+ session: { ...logoSession, status: 'completed', completedAt: now },
+ value: result,
+ alreadyCompleted: false,
+ })
+ const request = new NextRequest('http://localhost/api/files/uploads/upload-1/complete', {
+ method: 'POST',
+ headers: { 'upload-token': 'signed-token' },
+ })
+
+ const response = await completeUpload(request, {
+ params: Promise.resolve({ uploadId: 'upload-1' }),
+ })
+ const body = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(actor.id, 'workspace', 'workspace-1')
+ expect(mockCompleteUploadSession).toHaveBeenCalledWith(
+ expect.objectContaining({ session: logoSession })
+ )
+ expect(body).toEqual({
+ data: expect.objectContaining({
+ id: 'upload-1',
+ purpose: 'workspace_logo',
+ status: 'completed',
+ result,
+ }),
+ })
+ expect(body.data).not.toHaveProperty('uploadToken')
+ expect(body.data).not.toHaveProperty('transfer')
+ })
+
+ it('authenticates before parsing the request body', async () => {
+ mockGetSession.mockResolvedValue(null)
+ const request = new NextRequest('http://localhost/api/files/uploads', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: '{not json',
+ })
+
+ const response = await createUpload(request)
+
+ expect(response.status).toBe(401)
+ expect(mockCreateUploadSession).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/files/uploads/route.ts b/apps/sim/app/api/files/uploads/route.ts
new file mode 100644
index 00000000000..85752ea6e82
--- /dev/null
+++ b/apps/sim/app/api/files/uploads/route.ts
@@ -0,0 +1,39 @@
+import { type NextRequest, NextResponse } from 'next/server'
+import { createInternalFileUploadContract } from '@/lib/api/contracts/upload-sessions'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { createPurposeUploadSession } from '@/app/api/files/uploads/purposes'
+import {
+ requireUploadUser,
+ toInternalUploadSession,
+ uploadSessionErrorResponse,
+} from '@/app/api/files/uploads/utils'
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const actor = await requireUploadUser()
+ if (actor instanceof NextResponse) return actor
+ const parsed = await parseRequest(createInternalFileUploadContract, request, {})
+ if (!parsed.success) return parsed.response
+
+ try {
+ const created = await createPurposeUploadSession(
+ actor.id,
+ parsed.data.body,
+ request.nextUrl.origin
+ )
+ return NextResponse.json(
+ {
+ data: {
+ session: toInternalUploadSession(created, null),
+ uploadToken: created.uploadToken,
+ transfer: created.transfer,
+ },
+ },
+ { status: 201 }
+ )
+ } catch (error) {
+ const classified = uploadSessionErrorResponse(error)
+ if (classified) return classified
+ throw error
+ }
+})
diff --git a/apps/sim/app/api/files/uploads/utils.ts b/apps/sim/app/api/files/uploads/utils.ts
new file mode 100644
index 00000000000..ca8e9356cb3
--- /dev/null
+++ b/apps/sim/app/api/files/uploads/utils.ts
@@ -0,0 +1,48 @@
+import { NextResponse } from 'next/server'
+import {
+ type InternalFileUploadSession,
+ internalFileUploadSessionSchema,
+} from '@/lib/api/contracts/upload-sessions'
+import { getSession } from '@/lib/auth'
+import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types'
+import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service'
+import type { UploadActor, UploadPurposeResult } from '@/app/api/files/uploads/finalizers'
+
+export async function requireUploadUser(): Promise {
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+ return {
+ id: session.user.id,
+ name: session.user.name,
+ email: session.user.email,
+ }
+}
+
+export function uploadSessionErrorResponse(error: unknown): NextResponse | null {
+ const classified = asOrchestrationError(error)
+ return classified
+ ? NextResponse.json(
+ { error: classified.message },
+ { status: statusForOrchestrationError(classified.code) }
+ )
+ : null
+}
+
+export function toInternalUploadSession(
+ session: UploadSessionRecord,
+ result: UploadPurposeResult | null
+): InternalFileUploadSession {
+ return internalFileUploadSessionSchema.parse({
+ id: session.id,
+ purpose: session.purpose,
+ status: session.status,
+ name: session.fileName,
+ contentType: session.contentType,
+ size: session.fileSize,
+ expiresAt: session.expiresAt.toISOString(),
+ error: session.error,
+ result,
+ })
+}
diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts
index 9cd730013ae..0ffbe540e89 100644
--- a/apps/sim/app/api/folders/[id]/duplicate/route.ts
+++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts
@@ -13,8 +13,8 @@ import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { DbOrTx } from '@/lib/db/types'
-import { nextFolderSortOrder } from '@/lib/folders/lifecycle'
import { deduplicateFolderName } from '@/lib/folders/naming'
+import { nextFolderSortOrder } from '@/lib/folders/orchestration'
import { toFolderApi } from '@/lib/folders/queries'
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
diff --git a/apps/sim/app/api/folders/[id]/restore/route.ts b/apps/sim/app/api/folders/[id]/restore/route.ts
index 0022d3e8c8c..f67b0f38a2c 100644
--- a/apps/sim/app/api/folders/[id]/restore/route.ts
+++ b/apps/sim/app/api/folders/[id]/restore/route.ts
@@ -5,7 +5,7 @@ import { restoreFolderContract } from '@/lib/api/contracts'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { restoreFolder } from '@/lib/folders/lifecycle'
+import { restoreFolder } from '@/lib/folders/orchestration'
import { folderMutationStatus } from '@/lib/folders/status'
import { captureServerEvent } from '@/lib/posthog/server'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
diff --git a/apps/sim/app/api/folders/[id]/route.test.ts b/apps/sim/app/api/folders/[id]/route.test.ts
index 25db5804c3c..035223d827a 100644
--- a/apps/sim/app/api/folders/[id]/route.test.ts
+++ b/apps/sim/app/api/folders/[id]/route.test.ts
@@ -8,8 +8,8 @@ import {
authMockFns,
createMockRequest,
dbChainMockFns,
- foldersLifecycleMock,
- foldersLifecycleMockFns,
+ foldersOrchestrationMock,
+ foldersOrchestrationMockFns,
type MockUser,
permissionsMock,
permissionsMockFns,
@@ -34,8 +34,8 @@ const { mockLogger } = vi.hoisted(() => {
}
})
-const mockDeleteFolder = foldersLifecycleMockFns.mockDeleteFolder
-const mockUpdateFolder = foldersLifecycleMockFns.mockUpdateFolder
+const mockDeleteFolder = foldersOrchestrationMockFns.mockDeleteFolder
+const mockUpdateFolder = foldersOrchestrationMockFns.mockUpdateFolder
/** Parent ids the mocked engine treats as closing a cycle for the folder under test. */
const cyclicParentIds = new Set()
@@ -49,7 +49,7 @@ vi.mock('@sim/logger', () => ({
getRequestContext: () => undefined,
}))
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
-vi.mock('@/lib/folders/lifecycle', () => foldersLifecycleMock)
+vi.mock('@/lib/folders/orchestration', () => foldersOrchestrationMock)
import { DELETE, PUT } from '@/app/api/folders/[id]/route'
diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts
index 176e43a9aba..682f9f40497 100644
--- a/apps/sim/app/api/folders/[id]/route.ts
+++ b/apps/sim/app/api/folders/[id]/route.ts
@@ -10,7 +10,7 @@ import { getSession } from '@/lib/auth'
import { HttpError } from '@/lib/core/utils/http-error'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { folderResourceConfig } from '@/lib/folders/config'
-import { deleteFolder, updateFolder } from '@/lib/folders/lifecycle'
+import { deleteFolder, updateFolder } from '@/lib/folders/orchestration'
import { toFolderApi } from '@/lib/folders/queries'
import { folderMutationStatus } from '@/lib/folders/status'
import { captureServerEvent } from '@/lib/posthog/server'
diff --git a/apps/sim/app/api/folders/reorder/route.test.ts b/apps/sim/app/api/folders/reorder/route.test.ts
index 869aaa89f73..c9803b66fbb 100644
--- a/apps/sim/app/api/folders/reorder/route.test.ts
+++ b/apps/sim/app/api/folders/reorder/route.test.ts
@@ -31,21 +31,30 @@ describe('PUT /api/folders/reorder', () => {
const mockFrom = vi.fn()
const mockWhere = vi.fn()
const mockTxUpdate = vi.fn()
+ const mockTxExecute = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
+ mockFrom.mockReset()
+ mockWhere.mockReset()
+ mockTxUpdate.mockReset()
+ mockTxExecute.mockReset()
+ mockDb.transaction.mockReset()
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
mockGetUserEntityPermissions.mockResolvedValue('admin')
- mockDb.select.mockReturnValue({ from: mockFrom })
mockFrom.mockReturnValue({ where: mockWhere })
mockTxUpdate.mockReturnValue({
set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }),
})
mockDb.transaction.mockImplementation(async (cb: (tx: unknown) => Promise) =>
- cb({ update: mockTxUpdate })
+ cb({
+ execute: mockTxExecute,
+ select: vi.fn().mockReturnValue({ from: mockFrom }),
+ update: mockTxUpdate,
+ })
)
})
@@ -76,8 +85,8 @@ describe('PUT /api/folders/reorder', () => {
])
const uniqueViolation = Object.assign(new Error('duplicate key value'), { code: '23505' })
- mockDb.transaction.mockImplementationOnce(async () => {
- throw uniqueViolation
+ mockTxUpdate.mockReturnValueOnce({
+ set: vi.fn().mockReturnValue({ where: vi.fn().mockRejectedValue(uniqueViolation) }),
})
const req = createMockRequest('PUT', {
@@ -88,6 +97,7 @@ describe('PUT /api/folders/reorder', () => {
const response = await PUT(req)
+ expect(mockTxUpdate).toHaveBeenCalled()
expect(response.status).toBe(409)
const data = await response.json()
expect(data.error).toBe('A folder with this name already exists in this location')
@@ -108,7 +118,7 @@ describe('PUT /api/folders/reorder', () => {
expect(response.status).toBe(400)
const data = await response.json()
expect(data.error).toBe('Parent folder not found')
- expect(mockDb.transaction).not.toHaveBeenCalled()
+ expect(mockTxUpdate).not.toHaveBeenCalled()
})
it('rejects a batch that would form a cycle', async () => {
@@ -139,6 +149,6 @@ describe('PUT /api/folders/reorder', () => {
expect(response.status).toBe(400)
const data = await response.json()
expect(data.error).toBe('Cannot create circular folder reference')
- expect(mockDb.transaction).not.toHaveBeenCalled()
+ expect(mockTxUpdate).not.toHaveBeenCalled()
})
})
diff --git a/apps/sim/app/api/folders/reorder/route.ts b/apps/sim/app/api/folders/reorder/route.ts
index b27dbdabe37..dd644402927 100644
--- a/apps/sim/app/api/folders/reorder/route.ts
+++ b/apps/sim/app/api/folders/reorder/route.ts
@@ -1,4 +1,3 @@
-import { db } from '@sim/db'
import { folder as folderTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow'
@@ -10,7 +9,9 @@ import { 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 { withTransactionRetry } from '@/lib/db/transaction'
import { folderResourceConfig } from '@/lib/folders/config'
+import { acquireFolderMutationLock } from '@/lib/folders/locks'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('FolderReorderAPI')
@@ -37,136 +38,146 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
return NextResponse.json({ error: 'Write access required' }, { status: 403 })
}
- const folderIds = updates.map((u) => u.id)
- /**
- * Archived folders are excluded here for the same reason `PUT /api/folders/[id]` excludes
- * them: `getFolderLockStatus` skips archived rows, so `assertFolderMutable` below is a
- * guaranteed no-op on one — meaning a locked folder becomes freely reparentable the moment
- * its parent is deleted. Reordering an archived folder is also a correctness problem in its
- * own right: `collectArchivedSubtreeIds` walks the cascade by parent, so moving a branch out
- * of an archived subtree silently drops it from that folder's restore.
- */
- const existingFolders = await db
- .select({ id: folderTable.id, workspaceId: folderTable.workspaceId })
- .from(folderTable)
- .where(
- and(
- inArray(folderTable.id, folderIds),
- eq(folderTable.resourceType, resourceType),
- isNull(folderTable.deletedAt)
+ return await withTransactionRetry(
+ async (tx) => {
+ await acquireFolderMutationLock(tx, workspaceId, resourceType)
+ const folderIds = updates.map((u) => u.id)
+ /**
+ * Archived folders are excluded here for the same reason `PUT /api/folders/[id]`
+ * excludes them: lock resolution skips archived rows, so an archived-but-locked
+ * folder would otherwise become mutable while its cascade is still recoverable.
+ */
+ const existingFolders = await tx
+ .select({ id: folderTable.id, workspaceId: folderTable.workspaceId })
+ .from(folderTable)
+ .where(
+ and(
+ inArray(folderTable.id, folderIds),
+ eq(folderTable.resourceType, resourceType),
+ isNull(folderTable.deletedAt)
+ )
+ )
+
+ const validIds = new Set(
+ existingFolders.filter((f) => f.workspaceId === workspaceId).map((f) => f.id)
)
- )
+ const validUpdates = updates.filter((u) => validIds.has(u.id))
- const validIds = new Set(
- existingFolders.filter((f) => f.workspaceId === workspaceId).map((f) => f.id)
- )
+ if (validUpdates.length === 0) {
+ return NextResponse.json({ error: 'No valid folders to update' }, { status: 400 })
+ }
- const validUpdates = updates.filter((u) => validIds.has(u.id))
+ const targetParentIds = Array.from(
+ new Set(validUpdates.map((u) => u.parentId).filter((id): id is string => Boolean(id)))
+ )
- if (validUpdates.length === 0) {
- return NextResponse.json({ error: 'No valid folders to update' }, { status: 400 })
- }
+ if (targetParentIds.length > 0) {
+ const parentFolders = await tx
+ .select({
+ id: folderTable.id,
+ workspaceId: folderTable.workspaceId,
+ archivedAt: folderTable.deletedAt,
+ })
+ .from(folderTable)
+ .where(
+ and(
+ inArray(folderTable.id, targetParentIds),
+ eq(folderTable.resourceType, resourceType)
+ )
+ )
- const targetParentIds = Array.from(
- new Set(validUpdates.map((u) => u.parentId).filter((id): id is string => Boolean(id)))
- )
+ const validParentIds = new Set(
+ parentFolders
+ .filter((f) => f.workspaceId === workspaceId && !f.archivedAt)
+ .map((f) => f.id)
+ )
- if (targetParentIds.length > 0) {
- const parentFolders = await db
- .select({
- id: folderTable.id,
- workspaceId: folderTable.workspaceId,
- archivedAt: folderTable.deletedAt,
- })
- .from(folderTable)
- .where(
- and(inArray(folderTable.id, targetParentIds), eq(folderTable.resourceType, resourceType))
- )
+ for (const update of validUpdates) {
+ if (!update.parentId) continue
+ if (update.parentId === update.id) {
+ return NextResponse.json(
+ { error: 'Folder cannot be its own parent' },
+ { status: 400 }
+ )
+ }
+ if (!validParentIds.has(update.parentId)) {
+ return NextResponse.json({ error: 'Parent folder not found' }, { status: 400 })
+ }
+ }
+ }
- const validParentIds = new Set(
- parentFolders.filter((f) => f.workspaceId === workspaceId && !f.archivedAt).map((f) => f.id)
- )
+ const workspaceFolders = await tx
+ .select({ id: folderTable.id, parentId: folderTable.parentId })
+ .from(folderTable)
+ .where(
+ and(
+ eq(folderTable.workspaceId, workspaceId),
+ eq(folderTable.resourceType, resourceType)
+ )
+ )
- for (const update of validUpdates) {
- if (!update.parentId) continue
- if (update.parentId === update.id) {
- return NextResponse.json({ error: 'Folder cannot be its own parent' }, { status: 400 })
+ const parentById = new Map()
+ for (const folder of workspaceFolders) {
+ parentById.set(folder.id, folder.parentId)
}
- if (!validParentIds.has(update.parentId)) {
- return NextResponse.json({ error: 'Parent folder not found' }, { status: 400 })
+ for (const update of validUpdates) {
+ if (update.parentId !== undefined) {
+ parentById.set(update.id, update.parentId || null)
+ }
}
- }
- }
-
- const workspaceFolders = await db
- .select({ id: folderTable.id, parentId: folderTable.parentId })
- .from(folderTable)
- .where(
- and(eq(folderTable.workspaceId, workspaceId), eq(folderTable.resourceType, resourceType))
- )
- const parentById = new Map()
- for (const folder of workspaceFolders) {
- parentById.set(folder.id, folder.parentId)
- }
- for (const update of validUpdates) {
- if (update.parentId !== undefined) {
- parentById.set(update.id, update.parentId || null)
- }
- }
-
- for (const update of validUpdates) {
- const visited = new Set()
- let cursor: string | null = update.id
- while (cursor) {
- if (visited.has(cursor)) {
- return NextResponse.json(
- { error: 'Cannot create circular folder reference' },
- { status: 400 }
- )
+ for (const update of validUpdates) {
+ const visited = new Set()
+ let cursor: string | null = update.id
+ while (cursor) {
+ if (visited.has(cursor)) {
+ return NextResponse.json(
+ { error: 'Cannot create circular folder reference' },
+ { status: 400 }
+ )
+ }
+ visited.add(cursor)
+ cursor = parentById.get(cursor) ?? null
+ }
}
- visited.add(cursor)
- cursor = parentById.get(cursor) ?? null
- }
- }
- // Folder locking is a workflow-only feature; other resource types leave `locked` false.
- if (folderResourceConfig(resourceType).supportsLocking) {
- for (const update of validUpdates) {
- await assertFolderMutable(update.id)
- if (update.parentId !== undefined) {
- await assertFolderMutable(update.parentId)
+ if (folderResourceConfig(resourceType).supportsLocking) {
+ for (const update of validUpdates) {
+ await assertFolderMutable(update.id)
+ if (update.parentId !== undefined) {
+ await assertFolderMutable(update.parentId)
+ }
+ }
}
- }
- }
- await db.transaction(async (tx) => {
- for (const update of validUpdates) {
- const updateData: Partial = {
- sortOrder: update.sortOrder,
- updatedAt: new Date(),
- }
- if (update.parentId !== undefined) {
- updateData.parentId = update.parentId || null
- }
- await tx
- .update(folderTable)
- .set(updateData)
- .where(
- and(
- eq(folderTable.id, update.id),
- eq(folderTable.resourceType, resourceType),
- isNull(folderTable.deletedAt)
+ for (const update of validUpdates) {
+ const updateData: Partial = {
+ sortOrder: update.sortOrder,
+ updatedAt: new Date(),
+ }
+ if (update.parentId !== undefined) {
+ updateData.parentId = update.parentId || null
+ }
+ await tx
+ .update(folderTable)
+ .set(updateData)
+ .where(
+ and(
+ eq(folderTable.id, update.id),
+ eq(folderTable.resourceType, resourceType),
+ isNull(folderTable.deletedAt)
+ )
)
- )
- }
- })
+ }
- logger.info(
- `[${requestId}] Reordered ${validUpdates.length} ${resourceType} folders in workspace ${workspaceId}`
- )
+ logger.info(
+ `[${requestId}] Reordered ${validUpdates.length} ${resourceType} folders in workspace ${workspaceId}`
+ )
- return NextResponse.json({ success: true, updated: validUpdates.length })
+ return NextResponse.json({ success: true, updated: validUpdates.length })
+ },
+ { label: 'reorder-folders' }
+ )
} catch (error) {
if (error instanceof FolderLockedError) {
return NextResponse.json({ error: error.message }, { status: error.status })
diff --git a/apps/sim/app/api/folders/route.test.ts b/apps/sim/app/api/folders/route.test.ts
index bc6e72de1f0..65895a1d9b7 100644
--- a/apps/sim/app/api/folders/route.test.ts
+++ b/apps/sim/app/api/folders/route.test.ts
@@ -55,29 +55,36 @@ interface CapturedFolderValues {
function createMockTransaction(mockData: {
selectResults?: Array>
insertResult?: Array<{ id: string; [key: string]: unknown }>
+ insertError?: Error
onInsertValues?: (values: CapturedFolderValues) => void
}) {
- const { selectResults = [[], []], insertResult = [], onInsertValues } = mockData
+ const { selectResults = [[], []], insertResult = [], insertError, onInsertValues } = mockData
return async (callback: (tx: unknown) => Promise) => {
const where = vi.fn()
for (const result of selectResults) {
- where.mockReturnValueOnce(result)
+ const withLimit = result as typeof result & { limit: ReturnType }
+ withLimit.limit = vi.fn().mockReturnValue(result)
+ where.mockReturnValueOnce(withLimit)
}
where.mockReturnValue([])
const tx = {
+ execute: vi.fn(),
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where,
}),
}),
- insert: vi.fn().mockReturnValue({
- values: vi.fn().mockImplementation((values: CapturedFolderValues) => {
- onInsertValues?.(values)
- return {
- returning: vi.fn().mockReturnValue(insertResult),
- }
- }),
+ insert: vi.fn().mockImplementation(() => {
+ if (insertError) throw insertError
+ return {
+ values: vi.fn().mockImplementation((values: CapturedFolderValues) => {
+ onInsertValues?.(values)
+ return {
+ returning: vi.fn().mockReturnValue(insertResult),
+ }
+ }),
+ }
}),
}
return await callback(tx)
@@ -160,6 +167,7 @@ describe('Folders API Route', () => {
mockInsert.mockReturnValue({ values: mockValues })
mockValues.mockReturnValue({ returning: mockReturning })
mockReturning.mockReturnValue([mockFolders[0]])
+ mockTransaction.mockImplementation(createMockTransaction({}))
mockGetUserEntityPermissions.mockResolvedValue('admin')
})
@@ -363,7 +371,7 @@ describe('Folders API Route', () => {
mockTransaction.mockImplementationOnce(
createMockTransaction({
- selectResults: [[], []],
+ selectResults: [[{ workspaceId: 'workspace-123', archivedAt: null }], [], []],
insertResult: [{ ...mockFolders[1] }],
})
)
@@ -530,9 +538,9 @@ describe('Folders API Route', () => {
it('should handle database errors gracefully', async () => {
mockAuthenticatedUser()
- mockInsert.mockImplementationOnce(() => {
- throw new Error('Database insert failed')
- })
+ mockTransaction.mockImplementationOnce(
+ createMockTransaction({ insertError: new Error('Database insert failed') })
+ )
const req = createMockRequest('POST', {
name: 'Test Folder',
diff --git a/apps/sim/app/api/folders/route.ts b/apps/sim/app/api/folders/route.ts
index 84eba3c8cab..1ac1c31e9f1 100644
--- a/apps/sim/app/api/folders/route.ts
+++ b/apps/sim/app/api/folders/route.ts
@@ -6,7 +6,7 @@ import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { folderResourceConfig } from '@/lib/folders/config'
-import { createFolder } from '@/lib/folders/lifecycle'
+import { createFolder } from '@/lib/folders/orchestration'
import { listFoldersForWorkspace, toFolderApi } from '@/lib/folders/queries'
import { folderMutationStatus } from '@/lib/folders/status'
import { captureServerEvent } from '@/lib/posthog/server'
diff --git a/apps/sim/app/api/help/route.ts b/apps/sim/app/api/help/route.ts
index 3c10f68f4d4..88de0594622 100644
--- a/apps/sim/app/api/help/route.ts
+++ b/apps/sim/app/api/help/route.ts
@@ -20,7 +20,7 @@ const logger = createLogger('HelpAPI')
/**
* The form can carry several image attachments with no server-side count
* cap, so this reuses the repo's largest existing per-request form-data
- * bound (see files/upload route) rather than an arbitrary smaller limit
+ * multipart bound rather than an arbitrary smaller limit
* that could reject a legitimate multi-image submission.
*/
const MAX_HELP_FORM_BYTES = MAX_WORKSPACE_FORMDATA_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES
diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts
index bf347078d73..ce255768db1 100644
--- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts
+++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts
@@ -151,7 +151,10 @@ describe('Knowledge Connector By ID API Route', () => {
success: true,
userId: 'user-1',
})
- mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
+ mockCheckWriteAccess.mockResolvedValue({
+ hasAccess: true,
+ knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' },
+ })
dbChainMockFns.limit.mockResolvedValueOnce([])
const req = createMockRequest('PATCH', { sourceConfig: { project: 'NEW' } })
@@ -174,7 +177,8 @@ describe('Knowledge Connector By ID API Route', () => {
mockHasWorkspaceLiveSyncAccess.mockResolvedValue(true)
const updatedConnector = { id: 'conn-456', status: 'paused', syncIntervalMinutes: 5 }
- dbChainMockFns.limit.mockResolvedValueOnce([updatedConnector])
+ dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456', connectorType: 'jira' }])
+ dbChainMockFns.returning.mockResolvedValueOnce([updatedConnector])
const req = createMockRequest('PATCH', { status: 'paused', syncIntervalMinutes: 5 })
const response = await PATCH(req, { params: mockParams })
@@ -196,6 +200,7 @@ describe('Knowledge Connector By ID API Route', () => {
knowledgeBase: { workspaceId: 'ws-free', name: 'Free KB' },
})
mockHasWorkspaceLiveSyncAccess.mockResolvedValue(false)
+ dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456', connectorType: 'jira' }])
const req = createMockRequest('PATCH', { syncIntervalMinutes: 5 })
const response = await PATCH(req, { params: mockParams })
diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts
index 3ff1d479cd0..d63513af694 100644
--- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts
+++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts
@@ -1,20 +1,29 @@
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
-import { document, embedding, knowledgeConnector, knowledgeConnectorSyncLog } from '@sim/db/schema'
+import { knowledgeConnectorSyncLog } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm'
+import { desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
-import { updateKnowledgeConnectorContract } from '@/lib/api/contracts/knowledge'
+import {
+ deleteKnowledgeConnectorContract,
+ updateKnowledgeConnectorContract,
+} from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { decryptApiKey } from '@/lib/api-key/crypto'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
-import { hasWorkspaceLiveSyncAccess } from '@/lib/billing/core/subscription'
+import {
+ messageForOrchestrationError,
+ statusForOrchestrationError,
+} from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { resolveCredentialTokenIdentity } from '@/lib/credentials/access'
-import { deleteDocumentStorageFiles } from '@/lib/knowledge/documents/service'
-import { cleanupUnusedTagDefinitions } from '@/lib/knowledge/tags/service'
-import { captureServerEvent } from '@/lib/posthog/server'
+import {
+ getKnowledgeConnector,
+ type KnowledgeConnectorRow,
+ performDeleteKnowledgeConnector,
+ performUpdateKnowledgeConnector,
+ type SourceConfigRejection,
+} from '@/lib/knowledge/orchestration'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
import { CONNECTOR_REGISTRY } from '@/connectors/registry.server'
@@ -42,20 +51,8 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
}
- const connectorRows = await db
- .select()
- .from(knowledgeConnector)
- .where(
- and(
- eq(knowledgeConnector.id, connectorId),
- eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
- isNull(knowledgeConnector.archivedAt),
- isNull(knowledgeConnector.deletedAt)
- )
- )
- .limit(1)
-
- if (connectorRows.length === 0) {
+ const connector = await getKnowledgeConnector(knowledgeBaseId, connectorId)
+ if (!connector) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
@@ -66,7 +63,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou
.orderBy(desc(knowledgeConnectorSyncLog.startedAt))
.limit(10)
- const { encryptedApiKey: _, ...connectorData } = connectorRows[0]
+ const { encryptedApiKey: _, ...connectorData } = connector
return NextResponse.json({
success: true,
data: {
@@ -81,357 +78,179 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou
})
/**
- * PATCH /api/knowledge/[id]/connectors/[connectorId] - Update a connector
+ * Validates a replacement `sourceConfig` against the live source, resolving the
+ * connector's own token first. Returns a rejection message, or `null` to accept.
+ *
+ * Stays with the route rather than moving into orchestration because resolving
+ * the token needs the requesting identity: workspace credentials are shared and
+ * token reads are scoped to `account.userId`, so the credential's own account
+ * owner is used — not the knowledge base owner, and not the acting user when a
+ * service account mints its own token.
*/
-export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteParams) => {
- const requestId = generateRequestId()
- const { id: knowledgeBaseId, connectorId } = await context.params
-
- try {
- const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
- if (!auth.success || !auth.userId) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
- if (!writeCheck.hasAccess) {
- const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
- return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
- }
-
- const parsed = await parseRequest(updateKnowledgeConnectorContract, request, context)
- if (!parsed.success) return parsed.response
- const body = parsed.data.body
-
- if (
- body.syncIntervalMinutes !== undefined &&
- body.syncIntervalMinutes > 0 &&
- body.syncIntervalMinutes < 60
- ) {
- const workspaceId = writeCheck.knowledgeBase.workspaceId
- if (!workspaceId) {
- return NextResponse.json(
- { error: 'Knowledge base is missing workspace billing context' },
- { status: 409 }
- )
- }
- const canUseLiveSync = await hasWorkspaceLiveSyncAccess(workspaceId)
- if (!canUseLiveSync) {
- return NextResponse.json(
- { error: 'Live sync requires a Max or Enterprise plan' },
- { status: 403 }
- )
+function makeSourceConfigValidator(
+ actingUserId: string,
+ workspaceId: string | null,
+ connectorId: string
+) {
+ return async (
+ connector: KnowledgeConnectorRow,
+ sourceConfig: Record
+ ): Promise => {
+ const connectorConfig = CONNECTOR_REGISTRY[connector.connectorType]
+ if (!connectorConfig) {
+ return {
+ message: `Unknown connector type: ${connector.connectorType}`,
+ errorCode: 'validation',
}
}
- if (body.sourceConfig !== undefined) {
- const existingRows = await db
- .select()
- .from(knowledgeConnector)
- .where(
- and(
- eq(knowledgeConnector.id, connectorId),
- eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
- isNull(knowledgeConnector.archivedAt),
- isNull(knowledgeConnector.deletedAt)
- )
- )
- .limit(1)
-
- if (existingRows.length === 0) {
- return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
- }
-
- const existing = existingRows[0]
- const connectorConfig = CONNECTOR_REGISTRY[existing.connectorType]
-
- if (!connectorConfig) {
- return NextResponse.json(
- { error: `Unknown connector type: ${existing.connectorType}` },
- { status: 400 }
- )
- }
-
- let accessToken: string | null = null
- if (connectorConfig.auth.mode === 'apiKey') {
- if (!existing.encryptedApiKey) {
- return NextResponse.json(
- { error: 'API key not found. Please reconfigure the connector.' },
- { status: 400 }
- )
- }
- accessToken = (await decryptApiKey(existing.encryptedApiKey)).decrypted
- } else {
- if (!existing.credentialId) {
- return NextResponse.json(
- { error: 'OAuth credential not found. Please reconfigure the connector.' },
- { status: 400 }
- )
+ let accessToken: string | null = null
+ if (connectorConfig.auth.mode === 'apiKey') {
+ if (!connector.encryptedApiKey) {
+ return {
+ message: 'API key not found. Please reconfigure the connector.',
+ errorCode: 'validation',
}
- const connectorWorkspaceId = writeCheck.knowledgeBase.workspaceId
- if (!connectorWorkspaceId) {
- return NextResponse.json(
- { error: 'Knowledge base is missing workspace context' },
- { status: 409 }
- )
- }
- /**
- * Resolve the credential's own account owner, not the knowledge base owner:
- * workspace credentials are shared, and token reads are scoped to
- * `account.userId`.
- */
- const identity = await resolveCredentialTokenIdentity(
- existing.credentialId,
- connectorWorkspaceId
- )
- if (!identity) {
- return NextResponse.json(
- { error: 'Credential is no longer usable in this workspace. Please reconnect it.' },
- { status: 400 }
- )
+ }
+ accessToken = (await decryptApiKey(connector.encryptedApiKey)).decrypted
+ } else {
+ if (!connector.credentialId) {
+ return {
+ message: 'OAuth credential not found. Please reconfigure the connector.',
+ errorCode: 'validation',
}
- accessToken = await refreshAccessTokenIfNeeded(
- existing.credentialId,
- // Service accounts mint their own token and ignore the acting user.
- identity.kind === 'oauth' ? identity.userId : auth.userId,
- `patch-${connectorId}`
- )
}
-
- if (!accessToken) {
- return NextResponse.json(
- { error: 'Failed to refresh access token. Please reconnect your account.' },
- { status: 401 }
- )
+ if (!workspaceId) {
+ return {
+ message: 'Knowledge base is missing workspace context',
+ errorCode: 'conflict',
+ }
}
-
- const validation = await connectorConfig.validateConfig(accessToken, body.sourceConfig)
- if (!validation.valid) {
- return NextResponse.json(
- { error: validation.error || 'Invalid source configuration' },
- { status: 400 }
- )
+ const identity = await resolveCredentialTokenIdentity(connector.credentialId, workspaceId)
+ if (!identity) {
+ return {
+ message: 'Credential is no longer usable in this workspace. Please reconnect it.',
+ errorCode: 'validation',
+ }
}
+ accessToken = await refreshAccessTokenIfNeeded(
+ connector.credentialId,
+ // Service accounts mint their own token and ignore the acting user.
+ identity.kind === 'oauth' ? identity.userId : actingUserId,
+ `patch-${connectorId}`
+ )
}
- const updates: Record = { updatedAt: new Date() }
- if (body.sourceConfig !== undefined) {
- updates.sourceConfig = body.sourceConfig
- }
- if (body.syncIntervalMinutes !== undefined) {
- updates.syncIntervalMinutes = body.syncIntervalMinutes
- if (body.syncIntervalMinutes > 0) {
- updates.nextSyncAt = new Date(Date.now() + body.syncIntervalMinutes * 60 * 1000)
- } else {
- updates.nextSyncAt = null
- }
- }
- if (body.status !== undefined) {
- updates.status = body.status
- if (body.status === 'active') {
- updates.consecutiveFailures = 0
- updates.lastSyncError = null
- if (updates.nextSyncAt === undefined) {
- updates.nextSyncAt = new Date()
- }
+ if (!accessToken) {
+ // A stale stored credential, not an unauthenticated caller — but the route
+ // has always answered 401 here, so keep that rather than silently
+ // reclassifying it as part of this refactor.
+ return {
+ message: 'Failed to refresh access token. Please reconnect your account.',
+ errorCode: 'unauthorized',
}
}
- await db
- .update(knowledgeConnector)
- .set(updates)
- .where(
- and(
- eq(knowledgeConnector.id, connectorId),
- eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
- isNull(knowledgeConnector.archivedAt),
- isNull(knowledgeConnector.deletedAt)
- )
- )
+ const validation = await connectorConfig.validateConfig(accessToken, sourceConfig)
+ return validation.valid
+ ? null
+ : { message: validation.error || 'Invalid source configuration', errorCode: 'validation' }
+ }
+}
- const updated = await db
- .select()
- .from(knowledgeConnector)
- .where(
- and(
- eq(knowledgeConnector.id, connectorId),
- eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
- isNull(knowledgeConnector.archivedAt),
- isNull(knowledgeConnector.deletedAt)
- )
- )
- .limit(1)
+/**
+ * PATCH /api/knowledge/[id]/connectors/[connectorId] - Update a connector
+ */
+export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteParams) => {
+ const requestId = generateRequestId()
+ const { id: knowledgeBaseId, connectorId } = await context.params
- const { encryptedApiKey: __, ...updatedData } = updated[0]
+ const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
+ if (!auth.success || !auth.userId) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
- recordAudit({
- workspaceId: writeCheck.knowledgeBase.workspaceId,
- actorId: auth.userId,
- actorName: auth.userName,
- actorEmail: auth.userEmail,
- action: AuditAction.CONNECTOR_UPDATED,
- resourceType: AuditResourceType.CONNECTOR,
- resourceId: connectorId,
- resourceName: updatedData.connectorType,
- description: `Updated connector for knowledge base "${writeCheck.knowledgeBase.name}"`,
- metadata: {
- knowledgeBaseId,
- knowledgeBaseName: writeCheck.knowledgeBase.name,
- connectorType: updatedData.connectorType,
- updatedFields: Object.keys(parsed.data),
- ...(body.syncIntervalMinutes !== undefined && {
- syncIntervalMinutes: body.syncIntervalMinutes,
- }),
- ...(body.status !== undefined && { newStatus: body.status }),
- },
- request,
- })
+ const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
+ if (!writeCheck.hasAccess) {
+ const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
+ return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
+ }
- return NextResponse.json({ success: true, data: updatedData })
- } catch (error) {
- logger.error(`[${requestId}] Error updating connector`, error)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
+ const parsed = await parseRequest(updateKnowledgeConnectorContract, request, context)
+ if (!parsed.success) return parsed.response
+
+ const outcome = await performUpdateKnowledgeConnector({
+ knowledgeBase: {
+ id: knowledgeBaseId,
+ name: writeCheck.knowledgeBase.name,
+ workspaceId: writeCheck.knowledgeBase.workspaceId ?? null,
+ },
+ connectorId,
+ updates: parsed.data.body,
+ validateSourceConfig: makeSourceConfigValidator(
+ auth.userId,
+ writeCheck.knowledgeBase.workspaceId ?? null,
+ connectorId
+ ),
+ userId: auth.userId,
+ actorName: auth.userName,
+ actorEmail: auth.userEmail,
+ source: 'ui',
+ requestId,
+ request,
+ })
+ if (!outcome.success) {
+ return NextResponse.json(
+ { error: messageForOrchestrationError(outcome, 'Internal server error') },
+ { status: statusForOrchestrationError(outcome.errorCode) }
+ )
}
+
+ return NextResponse.json({ success: true, data: outcome.connector })
})
/**
* DELETE /api/knowledge/[id]/connectors/[connectorId] - Hard-delete a connector
*/
-export const DELETE = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => {
+export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteParams) => {
const requestId = generateRequestId()
- const { id: knowledgeBaseId, connectorId } = await params
-
- try {
- const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
- if (!auth.success || !auth.userId) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
- if (!writeCheck.hasAccess) {
- const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
- return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
- }
-
- const existingConnector = await db
- .select({ id: knowledgeConnector.id, connectorType: knowledgeConnector.connectorType })
- .from(knowledgeConnector)
- .where(
- and(
- eq(knowledgeConnector.id, connectorId),
- eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
- isNull(knowledgeConnector.archivedAt),
- isNull(knowledgeConnector.deletedAt)
- )
- )
- .limit(1)
-
- if (existingConnector.length === 0) {
- return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
- }
-
- const { searchParams } = new URL(request.url)
- const deleteDocuments = searchParams.get('deleteDocuments') === 'true'
-
- const { deletedDocs, docCount } = await db.transaction(async (tx) => {
- await tx.execute(sql`SELECT 1 FROM knowledge_connector WHERE id = ${connectorId} FOR UPDATE`)
-
- // Includes pending-removal (tombstoned) docs — the connector is being
- // deleted, so there's no future sync left to confirm or resurrect them.
- const docs = await tx
- .select({ id: document.id, fileUrl: document.fileUrl })
- .from(document)
- .where(and(eq(document.connectorId, connectorId), isNull(document.archivedAt)))
-
- const documentIds = docs.map((doc) => doc.id)
- if (deleteDocuments) {
- if (documentIds.length > 0) {
- await tx.delete(embedding).where(inArray(embedding.documentId, documentIds))
- await tx.delete(document).where(inArray(document.id, documentIds))
- }
- } else if (documentIds.length > 0) {
- // Kept documents become normal standalone KB entries once their connector
- // is gone — resurrect any pending-removal ones rather than leaving them
- // invisible tombstones with no future sync left to ever confirm or
- // resurrect them.
- await tx.update(document).set({ deletedAt: null }).where(inArray(document.id, documentIds))
- }
-
- const deletedConnectors = await tx
- .delete(knowledgeConnector)
- .where(
- and(
- eq(knowledgeConnector.id, connectorId),
- eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
- isNull(knowledgeConnector.archivedAt),
- isNull(knowledgeConnector.deletedAt)
- )
- )
- .returning({ id: knowledgeConnector.id })
-
- if (deletedConnectors.length === 0) {
- throw new Error('Connector not found')
- }
-
- return { deletedDocs: deleteDocuments ? docs : [], docCount: docs.length }
- })
-
- const kbWorkspaceId = writeCheck.knowledgeBase?.workspaceId ?? null
+ const { id: knowledgeBaseId, connectorId } = await context.params
- if (deleteDocuments) {
- await Promise.all([
- deletedDocs.length > 0
- ? deleteDocumentStorageFiles(
- deletedDocs.map((doc) => ({ ...doc, workspaceId: kbWorkspaceId })),
- requestId
- )
- : Promise.resolve(),
- cleanupUnusedTagDefinitions(knowledgeBaseId, requestId).catch((error) => {
- logger.warn(`[${requestId}] Failed to cleanup tag definitions`, error)
- }),
- ])
- }
+ const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
+ if (!auth.success || !auth.userId) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
- logger.info(
- `[${requestId}] Deleted connector ${connectorId}${deleteDocuments ? ` and ${docCount} documents` : `, kept ${docCount} documents`}`
- )
+ const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
+ if (!writeCheck.hasAccess) {
+ const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
+ return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
+ }
- captureServerEvent(
- auth.userId,
- 'knowledge_base_connector_removed',
- {
- knowledge_base_id: knowledgeBaseId,
- workspace_id: kbWorkspaceId ?? '',
- connector_type: existingConnector[0].connectorType,
- documents_deleted: deleteDocuments ? docCount : 0,
- },
- kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : undefined
+ const parsed = await parseRequest(deleteKnowledgeConnectorContract, request, context)
+ if (!parsed.success) return parsed.response
+
+ const outcome = await performDeleteKnowledgeConnector({
+ knowledgeBase: {
+ id: knowledgeBaseId,
+ name: writeCheck.knowledgeBase.name,
+ workspaceId: writeCheck.knowledgeBase.workspaceId ?? null,
+ },
+ connectorId,
+ deleteDocuments: parsed.data.query.deleteDocuments,
+ userId: auth.userId,
+ actorName: auth.userName,
+ actorEmail: auth.userEmail,
+ source: 'ui',
+ requestId,
+ request,
+ })
+ if (!outcome.success) {
+ return NextResponse.json(
+ { error: messageForOrchestrationError(outcome, 'Internal server error') },
+ { status: statusForOrchestrationError(outcome.errorCode) }
)
-
- recordAudit({
- workspaceId: writeCheck.knowledgeBase.workspaceId,
- actorId: auth.userId,
- actorName: auth.userName,
- actorEmail: auth.userEmail,
- action: AuditAction.CONNECTOR_DELETED,
- resourceType: AuditResourceType.CONNECTOR,
- resourceId: connectorId,
- resourceName: existingConnector[0].connectorType,
- description: `Deleted connector from knowledge base "${writeCheck.knowledgeBase.name}"`,
- metadata: {
- knowledgeBaseId,
- knowledgeBaseName: writeCheck.knowledgeBase.name,
- connectorType: existingConnector[0].connectorType,
- deleteDocuments,
- documentsDeleted: deleteDocuments ? docCount : 0,
- documentsKept: deleteDocuments ? 0 : docCount,
- },
- request,
- })
-
- return NextResponse.json({ success: true })
- } catch (error) {
- logger.error(`[${requestId}] Error deleting connector`, error)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
+
+ return NextResponse.json({ success: true })
})
diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts
index c79c85df58a..b8869013644 100644
--- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts
+++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts
@@ -62,7 +62,10 @@ describe('Connector Manual Sync API Route', () => {
success: true,
userId: 'user-1',
})
- mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
+ mockCheckWriteAccess.mockResolvedValue({
+ hasAccess: true,
+ knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' },
+ })
dbChainMockFns.limit.mockResolvedValueOnce([])
const req = createMockRequest('POST')
@@ -76,7 +79,10 @@ describe('Connector Manual Sync API Route', () => {
success: true,
userId: 'user-1',
})
- mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
+ mockCheckWriteAccess.mockResolvedValue({
+ hasAccess: true,
+ knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' },
+ })
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'syncing' }])
const req = createMockRequest('POST')
diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts
index 714f554040b..21e6bfdb50e 100644
--- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts
+++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts
@@ -1,8 +1,3 @@
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
-import { db } from '@sim/db'
-import { knowledgeConnector } from '@sim/db/schema'
-import { createLogger } from '@sim/logger'
-import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { triggerKnowledgeConnectorSyncContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
@@ -11,14 +6,15 @@ import {
requireBillingAttributionHeader,
resolveBillingAttribution,
} from '@/lib/billing/core/billing-attribution'
+import {
+ messageForOrchestrationError,
+ statusForOrchestrationError,
+} from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { dispatchSync } from '@/lib/knowledge/connectors/queue'
-import { captureServerEvent } from '@/lib/posthog/server'
+import { performSyncKnowledgeConnector } from '@/lib/knowledge/orchestration'
import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
-const logger = createLogger('ConnectorManualSyncAPI')
-
type RouteParams = { params: Promise<{ id: string; connectorId: string }> }
/**
@@ -31,105 +27,50 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route
const { id: knowledgeBaseId, connectorId } = parsed.data.params
const { rehydrate } = parsed.data.query
- try {
- const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
- if (!auth.success || !auth.userId) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
- if (!writeCheck.hasAccess) {
- const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
- return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
- }
-
- const connectorRows = await db
- .select()
- .from(knowledgeConnector)
- .where(
- and(
- eq(knowledgeConnector.id, connectorId),
- eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
- isNull(knowledgeConnector.archivedAt),
- isNull(knowledgeConnector.deletedAt)
- )
- )
- .limit(1)
+ const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
+ if (!auth.success || !auth.userId) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
- if (connectorRows.length === 0) {
- return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
- }
+ const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
+ if (!writeCheck.hasAccess) {
+ const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
+ return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
+ }
- if (connectorRows[0].status === 'syncing') {
- return NextResponse.json({ error: 'Sync already in progress' }, { status: 409 })
- }
+ const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId ?? null
- const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId
- if (!kbWorkspaceId) {
- return NextResponse.json(
- { error: 'Knowledge base is missing workspace billing context' },
- { status: 409 }
- )
- }
- const billingAttribution =
+ const outcome = await performSyncKnowledgeConnector({
+ knowledgeBase: {
+ id: knowledgeBaseId,
+ name: writeCheck.knowledgeBase.name,
+ workspaceId: kbWorkspaceId,
+ },
+ connectorId,
+ resolveBillingAttribution: async () =>
auth.authType === AuthType.INTERNAL_JWT
? requireBillingAttributionHeader(request.headers, {
- actorUserId: auth.userId,
- workspaceId: kbWorkspaceId,
- })
- : await resolveBillingAttribution({
- actorUserId: auth.userId,
- workspaceId: kbWorkspaceId,
+ actorUserId: auth.userId as string,
+ workspaceId: kbWorkspaceId as string,
})
-
- logger.info(
- `[${requestId}] Manual sync${rehydrate ? ' (full rehydrate)' : ''} triggered for connector ${connectorId}`
- )
-
- captureServerEvent(
- auth.userId,
- 'knowledge_base_connector_synced',
- {
- knowledge_base_id: knowledgeBaseId,
- workspace_id: kbWorkspaceId,
- connector_type: connectorRows[0].connectorType,
- },
- kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : undefined
+ : resolveBillingAttribution({
+ actorUserId: auth.userId as string,
+ workspaceId: kbWorkspaceId as string,
+ }),
+ rehydrate,
+ userId: auth.userId,
+ actorName: auth.userName,
+ actorEmail: auth.userEmail,
+ source: 'ui',
+ requestId,
+ request,
+ })
+ if (!outcome.success) {
+ return NextResponse.json(
+ { error: messageForOrchestrationError(outcome, 'Internal server error') },
+ { status: statusForOrchestrationError(outcome.errorCode) }
)
-
- recordAudit({
- workspaceId: writeCheck.knowledgeBase.workspaceId,
- actorId: auth.userId,
- actorName: auth.userName,
- actorEmail: auth.userEmail,
- action: AuditAction.CONNECTOR_SYNCED,
- resourceType: AuditResourceType.CONNECTOR,
- resourceId: connectorId,
- resourceName: connectorRows[0].connectorType,
- description: `Triggered manual sync for connector on knowledge base "${writeCheck.knowledgeBase.name}"`,
- metadata: {
- knowledgeBaseId,
- knowledgeBaseName: writeCheck.knowledgeBase.name,
- connectorType: connectorRows[0].connectorType,
- connectorStatus: connectorRows[0].status,
- syncType: rehydrate ? 'manual-rehydrate' : 'manual',
- },
- request,
- })
-
- dispatchSync(connectorId, { billingAttribution, requestId, rehydrate }).catch((error) => {
- logger.error(
- `[${requestId}] Failed to dispatch manual sync for connector ${connectorId}`,
- error
- )
- })
-
- return NextResponse.json({
- success: true,
- message: 'Sync triggered',
- })
- } catch (error) {
- logger.error(`[${requestId}] Error triggering manual sync`, error)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
+
+ return NextResponse.json({ success: true, message: 'Sync triggered' })
})
diff --git a/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts
index 6087572fb40..361a8e2ad68 100644
--- a/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts
+++ b/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts
@@ -118,7 +118,8 @@ describe('Knowledge Connectors API Route', () => {
})
mockHasWorkspaceLiveSyncAccess.mockResolvedValue(true)
mockResolveBillingAttribution.mockResolvedValue(BILLING_ATTRIBUTION)
- dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'knowledge-base-1' }]).mockResolvedValueOnce([
+ dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'knowledge-base-1' }])
+ dbChainMockFns.returning.mockResolvedValueOnce([
{
id: 'connector-1',
knowledgeBaseId: 'knowledge-base-1',
@@ -173,6 +174,8 @@ describe('Knowledge Connectors API Route', () => {
expect(response.status).toBe(403)
expect(mockHasWorkspaceLiveSyncAccess).toHaveBeenCalledWith('workspace-free')
+ // The payer is resolved lazily, so a request the plan gate rejects never
+ // pays for the lookup.
expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
expect(mockDispatchSync).not.toHaveBeenCalled()
})
diff --git a/apps/sim/app/api/knowledge/[id]/connectors/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/route.ts
index b7df3198990..df2f246ae1d 100644
--- a/apps/sim/app/api/knowledge/[id]/connectors/route.ts
+++ b/apps/sim/app/api/knowledge/[id]/connectors/route.ts
@@ -1,28 +1,25 @@
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
-import { knowledgeBase, knowledgeBaseTagDefinitions, knowledgeConnector } from '@sim/db/schema'
+import { knowledgeConnector } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { generateId } from '@sim/utils/id'
-import { and, desc, eq, isNull, sql } from 'drizzle-orm'
+import { and, desc, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { createKnowledgeConnectorContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
-import { encryptApiKey } from '@/lib/api-key/crypto'
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import {
requireBillingAttributionHeader,
resolveBillingAttribution,
} from '@/lib/billing/core/billing-attribution'
-import { hasWorkspaceLiveSyncAccess } from '@/lib/billing/core/subscription'
+import {
+ messageForOrchestrationError,
+ OrchestrationError,
+ statusForOrchestrationError,
+} from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { dispatchSync } from '@/lib/knowledge/connectors/queue'
-import { allocateTagSlots } from '@/lib/knowledge/constants'
-import { createTagDefinition } from '@/lib/knowledge/tags/service'
-import { captureServerEvent } from '@/lib/posthog/server'
+import { performCreateKnowledgeConnector } from '@/lib/knowledge/orchestration'
import { getCredential } from '@/app/api/auth/oauth/utils'
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
-import { CONNECTOR_REGISTRY } from '@/connectors/registry.server'
const logger = createLogger('KnowledgeConnectorsAPI')
@@ -80,263 +77,71 @@ export const POST = withRouteHandler(
const requestId = generateRequestId()
const { id: knowledgeBaseId } = await context.params
- try {
- const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
- if (!auth.success || !auth.userId) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
- if (!writeCheck.hasAccess) {
- const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
- return NextResponse.json(
- { error: status === 404 ? 'Not found' : 'Unauthorized' },
- { status }
- )
- }
+ const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
+ if (!auth.success || !auth.userId) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
- const parsed = await parseRequest(createKnowledgeConnectorContract, request, context)
- if (!parsed.success) return parsed.response
+ const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
+ if (!writeCheck.hasAccess) {
+ const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
+ return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
+ }
- const { connectorType, credentialId, apiKey, sourceConfig, syncIntervalMinutes } =
- parsed.data.body
+ const parsed = await parseRequest(createKnowledgeConnectorContract, request, context)
+ if (!parsed.success) return parsed.response
- const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId
- if (!kbWorkspaceId) {
- return NextResponse.json(
- { error: 'Knowledge base is missing workspace billing context' },
- { status: 409 }
- )
- }
+ const { connectorType, credentialId, apiKey, sourceConfig, syncIntervalMinutes } =
+ parsed.data.body
- if (syncIntervalMinutes > 0 && syncIntervalMinutes < 60) {
- const canUseLiveSync = await hasWorkspaceLiveSyncAccess(kbWorkspaceId)
- if (!canUseLiveSync) {
- return NextResponse.json(
- { error: 'Live sync requires a Max or Enterprise plan' },
- { status: 403 }
- )
- }
- }
+ const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId
+ if (!kbWorkspaceId) {
+ return NextResponse.json(
+ { error: 'Knowledge base is missing workspace billing context' },
+ { status: 409 }
+ )
+ }
- const billingAttribution =
+ const outcome = await performCreateKnowledgeConnector({
+ knowledgeBase: {
+ id: knowledgeBaseId,
+ name: writeCheck.knowledgeBase.name,
+ workspaceId: kbWorkspaceId,
+ },
+ connectorType,
+ credentialId,
+ apiKey,
+ sourceConfig,
+ syncIntervalMinutes,
+ resolveBillingAttribution: async () =>
auth.authType === AuthType.INTERNAL_JWT
? requireBillingAttributionHeader(request.headers, {
- actorUserId: auth.userId,
+ actorUserId: auth.userId as string,
workspaceId: kbWorkspaceId,
})
- : await resolveBillingAttribution({
- actorUserId: auth.userId,
+ : resolveBillingAttribution({
+ actorUserId: auth.userId as string,
workspaceId: kbWorkspaceId,
- })
-
- const connectorConfig = CONNECTOR_REGISTRY[connectorType]
- if (!connectorConfig) {
- return NextResponse.json(
- { error: `Unknown connector type: ${connectorType}` },
- { status: 400 }
- )
- }
-
- let resolvedCredentialId: string | null = null
- let resolvedEncryptedApiKey: string | null = null
- let accessToken: string
-
- if (connectorConfig.auth.mode === 'apiKey') {
- if (!apiKey) {
- return NextResponse.json({ error: 'API key is required' }, { status: 400 })
- }
- accessToken = apiKey
- } else {
- if (!credentialId) {
- return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
- }
-
- const credential = await getCredential(requestId, credentialId, auth.userId)
- if (!credential) {
- return NextResponse.json({ error: 'Credential not found' }, { status: 400 })
- }
-
- if (!credential.accessToken) {
- return NextResponse.json(
- { error: 'Credential has no access token. Please reconnect your account.' },
- { status: 400 }
- )
- }
-
- accessToken = credential.accessToken
- resolvedCredentialId = credentialId
- }
-
- const configValidation = await connectorConfig.validateConfig(accessToken, sourceConfig)
- if (!configValidation.valid) {
- return NextResponse.json(
- { error: configValidation.error || 'Invalid source configuration' },
- { status: 400 }
- )
- }
-
- let finalSourceConfig: Record = { ...sourceConfig }
-
- if (connectorConfig.auth.mode === 'apiKey' && apiKey) {
- const { encrypted } = await encryptApiKey(apiKey)
- resolvedEncryptedApiKey = encrypted
- }
-
- const tagSlotMapping: Record = {}
- let newTagSlots: Record = {}
-
- if (connectorConfig.tagDefinitions?.length) {
- const disabledIds = new Set((sourceConfig.disabledTagIds as string[] | undefined) ?? [])
- const enabledDefs = connectorConfig.tagDefinitions.filter((td) => !disabledIds.has(td.id))
-
- const existingDefs = await db
- .select({
- tagSlot: knowledgeBaseTagDefinitions.tagSlot,
- displayName: knowledgeBaseTagDefinitions.displayName,
- fieldType: knowledgeBaseTagDefinitions.fieldType,
- })
- .from(knowledgeBaseTagDefinitions)
- .where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId))
-
- const usedSlots = new Set(existingDefs.map((d) => d.tagSlot))
- const existingByName = new Map(
- existingDefs.map((d) => [d.displayName, { tagSlot: d.tagSlot, fieldType: d.fieldType }])
- )
-
- const defsNeedingSlots: typeof enabledDefs = []
- for (const td of enabledDefs) {
- const existing = existingByName.get(td.displayName)
- if (existing && existing.fieldType === td.fieldType) {
- tagSlotMapping[td.id] = existing.tagSlot
- } else {
- defsNeedingSlots.push(td)
- }
- }
-
- const { mapping, skipped: skippedTags } = allocateTagSlots(defsNeedingSlots, usedSlots)
- Object.assign(tagSlotMapping, mapping)
- newTagSlots = mapping
-
- for (const name of skippedTags) {
- logger.warn(`[${requestId}] No available slots for "${name}"`)
- }
-
- if (skippedTags.length > 0 && Object.keys(tagSlotMapping).length === 0) {
- return NextResponse.json(
- { error: `No available tag slots. Could not assign: ${skippedTags.join(', ')}` },
- { status: 422 }
- )
- }
-
- finalSourceConfig = { ...finalSourceConfig, tagSlotMapping }
- }
-
- const now = new Date()
- const connectorId = generateId()
- const nextSyncAt =
- syncIntervalMinutes > 0 ? new Date(now.getTime() + syncIntervalMinutes * 60 * 1000) : null
-
- await db.transaction(async (tx) => {
- await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`)
-
- const activeKb = await tx
- .select({ id: knowledgeBase.id })
- .from(knowledgeBase)
- .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt)))
- .limit(1)
-
- if (activeKb.length === 0) {
- throw new Error('Knowledge base not found')
- }
-
- for (const [semanticId, slot] of Object.entries(newTagSlots)) {
- const td = connectorConfig.tagDefinitions!.find((d) => d.id === semanticId)!
- await createTagDefinition(
- {
- knowledgeBaseId,
- tagSlot: slot,
- displayName: td.displayName,
- fieldType: td.fieldType,
- },
- requestId,
- tx
- )
- }
-
- await tx.insert(knowledgeConnector).values({
- id: connectorId,
- knowledgeBaseId,
- connectorType,
- credentialId: resolvedCredentialId,
- encryptedApiKey: resolvedEncryptedApiKey,
- sourceConfig: finalSourceConfig,
- syncIntervalMinutes,
- status: 'active',
- nextSyncAt,
- createdAt: now,
- updatedAt: now,
- })
- })
-
- logger.info(`[${requestId}] Created connector ${connectorId} for KB ${knowledgeBaseId}`)
-
- captureServerEvent(
- auth.userId,
- 'knowledge_base_connector_added',
- {
- knowledge_base_id: knowledgeBaseId,
- workspace_id: kbWorkspaceId,
- connector_type: connectorType,
- sync_interval_minutes: syncIntervalMinutes,
- },
- {
- groups: kbWorkspaceId ? { workspace: kbWorkspaceId } : undefined,
- setOnce: { first_connector_added_at: new Date().toISOString() },
- }
+ }),
+ resolveAccessToken: async (id) => {
+ const credential = await getCredential(requestId, id, auth.userId as string)
+ if (!credential) throw new OrchestrationError('validation', 'Credential not found')
+ return credential.accessToken ?? null
+ },
+ userId: auth.userId,
+ actorName: auth.userName,
+ actorEmail: auth.userEmail,
+ source: 'ui',
+ requestId,
+ request,
+ })
+ if (!outcome.success) {
+ return NextResponse.json(
+ { error: messageForOrchestrationError(outcome, 'Internal server error') },
+ { status: statusForOrchestrationError(outcome.errorCode) }
)
-
- recordAudit({
- workspaceId: writeCheck.knowledgeBase.workspaceId,
- actorId: auth.userId,
- actorName: auth.userName,
- actorEmail: auth.userEmail,
- action: AuditAction.CONNECTOR_CREATED,
- resourceType: AuditResourceType.CONNECTOR,
- resourceId: connectorId,
- resourceName: connectorType,
- description: `Created ${connectorType} connector for knowledge base "${writeCheck.knowledgeBase.name}"`,
- metadata: {
- knowledgeBaseId,
- knowledgeBaseName: writeCheck.knowledgeBase.name,
- connectorType,
- syncIntervalMinutes,
- authMode: connectorConfig.auth.mode,
- },
- request,
- })
-
- dispatchSync(connectorId, { billingAttribution, requestId }).catch((error) => {
- logger.error(
- `[${requestId}] Failed to dispatch initial sync for connector ${connectorId}`,
- error
- )
- })
-
- const created = await db
- .select()
- .from(knowledgeConnector)
- .where(eq(knowledgeConnector.id, connectorId))
- .limit(1)
-
- const { encryptedApiKey: _, ...createdData } = created[0]
- return NextResponse.json({ success: true, data: createdData }, { status: 201 })
- } catch (error) {
- if (error instanceof Error && error.message === 'Knowledge base not found') {
- return NextResponse.json({ error: 'Not found' }, { status: 404 })
- }
- logger.error(`[${requestId}] Error creating connector`, error)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
+
+ return NextResponse.json({ success: true, data: outcome.connector }, { status: 201 })
}
)
diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts
index 7acdc821391..dab693e462f 100644
--- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts
+++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts
@@ -1,4 +1,3 @@
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { updateKnowledgeDocumentContract } from '@/lib/api/contracts/knowledge'
@@ -8,15 +7,19 @@ import {
requireBillingAttributionHeader,
resolveBillingAttribution,
} from '@/lib/billing/core/billing-attribution'
+import {
+ messageForOrchestrationError,
+ type OrchestrationErrorCode,
+ statusForOrchestrationError,
+} from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
- deleteDocument,
- markDocumentAsFailedTimeout,
- retryDocumentProcessing,
- updateDocument,
-} from '@/lib/knowledge/documents/service'
-import { captureServerEvent } from '@/lib/posthog/server'
+ performDeleteKnowledgeDocument,
+ performMarkKnowledgeDocumentTimedOut,
+ performRetryKnowledgeDocumentProcessing,
+ performUpdateKnowledgeDocument,
+} from '@/lib/knowledge/orchestration'
import { checkDocumentAccess, checkDocumentWriteAccess } from '@/app/api/knowledge/utils'
const logger = createLogger('DocumentByIdAPI')
@@ -108,58 +111,30 @@ export const PUT = withRouteHandler(
)
if (!parsed.success) return parsed.response
- const validatedData = parsed.data.body
-
- const updateData: any = {}
+ const { markFailedDueToTimeout, retryProcessing, ...documentUpdates } = parsed.data.body
+ const doc = accessCheck.document
+ const workspaceId = accessCheck.knowledgeBase?.workspaceId ?? null
- if (validatedData.markFailedDueToTimeout) {
- const doc = accessCheck.document
-
- if (doc.processingStatus !== 'processing') {
- return NextResponse.json(
- { error: `Document is not in processing state (current: ${doc.processingStatus})` },
- { status: 400 }
- )
- }
-
- if (!doc.processingStartedAt) {
- return NextResponse.json(
- { error: 'Document has no processing start time' },
- { status: 400 }
- )
- }
-
- try {
- await markDocumentAsFailedTimeout(documentId, doc.processingStartedAt, requestId)
+ const failed = (outcome: { error?: string; errorCode?: OrchestrationErrorCode }) =>
+ NextResponse.json(
+ { error: messageForOrchestrationError(outcome, 'Failed to update document') },
+ { status: statusForOrchestrationError(outcome.errorCode) }
+ )
- return NextResponse.json({
- success: true,
- data: {
- documentId,
- status: 'failed',
- message: 'Document marked as failed due to timeout',
- },
- })
- } catch (error) {
- if (error instanceof Error) {
- return NextResponse.json({ error: error.message }, { status: 400 })
- }
- throw error
- }
- } else if (validatedData.retryProcessing) {
- const doc = accessCheck.document
+ if (markFailedDueToTimeout) {
+ const outcome = await performMarkKnowledgeDocumentTimedOut({
+ document: doc,
+ requestId,
+ })
+ if (!outcome.success) return failed(outcome)
- if (doc.processingStatus !== 'failed') {
- return NextResponse.json({ error: 'Document is not in failed state' }, { status: 400 })
- }
+ return NextResponse.json({
+ success: true,
+ data: { documentId, status: outcome.status, message: outcome.message },
+ })
+ }
- const docData = {
- filename: doc.filename,
- fileUrl: doc.fileUrl,
- fileSize: doc.fileSize,
- mimeType: doc.mimeType,
- }
- const workspaceId = accessCheck.knowledgeBase?.workspaceId
+ if (retryProcessing) {
const billingAttribution = workspaceId
? auth.authType === AuthType.INTERNAL_JWT
? requireBillingAttributionHeader(req.headers, {
@@ -172,56 +147,38 @@ export const PUT = withRouteHandler(
})
: undefined
- const result = await retryDocumentProcessing(
+ const outcome = await performRetryKnowledgeDocumentProcessing({
knowledgeBaseId,
- documentId,
- docData,
+ document: doc,
+ billingAttribution,
requestId,
- billingAttribution
- )
-
- return NextResponse.json({
- success: true,
- data: {
- documentId,
- status: result.status,
- message: result.message,
- },
- })
- } else {
- const updatedDocument = await updateDocument(documentId, validatedData, requestId)
-
- logger.info(
- `[${requestId}] Document updated: ${documentId} in knowledge base ${knowledgeBaseId}`
- )
-
- recordAudit({
- workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
- actorId: userId,
- actorName: auth.userName,
- actorEmail: auth.userEmail,
- action: AuditAction.DOCUMENT_UPDATED,
- resourceType: AuditResourceType.DOCUMENT,
- resourceId: documentId,
- resourceName: validatedData.filename ?? accessCheck.document?.filename,
- description: `Updated document "${validatedData.filename ?? accessCheck.document?.filename}" in knowledge base "${knowledgeBaseId}"`,
- metadata: {
- knowledgeBaseId,
- knowledgeBaseName: accessCheck.knowledgeBase?.name,
- fileName: validatedData.filename ?? accessCheck.document?.filename,
- updatedFields: Object.keys(validatedData).filter(
- (k) => validatedData[k as keyof typeof validatedData] !== undefined
- ),
- ...(validatedData.enabled !== undefined && { enabled: validatedData.enabled }),
- },
- request: req,
})
+ if (!outcome.success) return failed(outcome)
return NextResponse.json({
success: true,
- data: updatedDocument,
+ data: { documentId, status: outcome.status, message: outcome.message },
})
}
+
+ const outcome = await performUpdateKnowledgeDocument({
+ knowledgeBase: {
+ id: knowledgeBaseId,
+ name: accessCheck.knowledgeBase?.name,
+ workspaceId,
+ },
+ document: { id: documentId, filename: doc.filename },
+ updates: documentUpdates,
+ userId,
+ actorName: auth.userName,
+ actorEmail: auth.userEmail,
+ source: 'ui',
+ requestId,
+ request: req,
+ })
+ if (!outcome.success) return failed(outcome)
+
+ return NextResponse.json({ success: true, data: outcome.document })
} catch (error) {
logger.error(`[${requestId}] Error updating document ${documentId}`, error)
return NextResponse.json({ error: 'Failed to update document' }, { status: 500 })
@@ -257,43 +214,30 @@ export const DELETE = withRouteHandler(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
- const result = await deleteDocument(documentId, requestId)
-
- logger.info(
- `[${requestId}] Document deleted: ${documentId} from knowledge base ${knowledgeBaseId}`
- )
-
- recordAudit({
- workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
- actorId: userId,
+ const outcome = await performDeleteKnowledgeDocument({
+ knowledgeBase: {
+ id: knowledgeBaseId,
+ name: accessCheck.knowledgeBase?.name,
+ workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
+ },
+ document: accessCheck.document,
+ userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
- action: AuditAction.DOCUMENT_DELETED,
- resourceType: AuditResourceType.DOCUMENT,
- resourceId: documentId,
- resourceName: accessCheck.document?.filename,
- description: `Deleted document "${accessCheck.document?.filename}" from knowledge base "${knowledgeBaseId}"`,
- metadata: {
- knowledgeBaseId,
- knowledgeBaseName: accessCheck.knowledgeBase?.name,
- fileName: accessCheck.document?.filename,
- fileSize: accessCheck.document?.fileSize,
- mimeType: accessCheck.document?.mimeType,
- },
+ source: 'ui',
+ requestId,
request: req,
})
-
- const kbWorkspaceId = accessCheck.knowledgeBase?.workspaceId ?? ''
- captureServerEvent(
- userId,
- 'knowledge_base_document_deleted',
- { knowledge_base_id: knowledgeBaseId, workspace_id: kbWorkspaceId },
- kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : undefined
- )
+ if (!outcome.success) {
+ return NextResponse.json(
+ { error: messageForOrchestrationError(outcome, 'Failed to delete document') },
+ { status: statusForOrchestrationError(outcome.errorCode) }
+ )
+ }
return NextResponse.json({
success: true,
- data: result,
+ data: { success: true, message: 'Document deleted successfully' },
})
} catch (error) {
logger.error(`[${requestId}] Error deleting document`, error)
diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts
index 971c4a8f28b..84b523c7870 100644
--- a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts
+++ b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts
@@ -570,7 +570,9 @@ describe('Knowledge Base Documents API Route', () => {
const data = await response.json()
expect(response.status).toBe(500)
- expect(data.error).toBe('Database error')
+ // An unclassified fault renders the route's own wording; the driver's
+ // message is logged, not returned.
+ expect(data.error).toBe('Failed to create document')
})
})
})
diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/documents/route.ts
index 9025cea9891..a46d08abae4 100644
--- a/apps/sim/app/api/knowledge/[id]/documents/route.ts
+++ b/apps/sim/app/api/knowledge/[id]/documents/route.ts
@@ -1,4 +1,3 @@
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { getErrorMessage } from '@sim/utils/errors'
@@ -19,19 +18,22 @@ import {
requireBillingAttributionHeader,
resolveBillingAttribution,
} from '@/lib/billing/core/billing-attribution'
+import {
+ messageForOrchestrationError,
+ statusForOrchestrationError,
+} from '@/lib/core/orchestration/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
bulkDocumentOperation,
bulkDocumentOperationByFilter,
- createDocumentRecords,
- createSingleDocument,
getDocuments,
getProcessingConfig,
- KnowledgeBaseFileOwnershipError,
- processDocumentsWithQueue,
} from '@/lib/knowledge/documents/service'
import type { TagFilterCondition } from '@/lib/knowledge/documents/tag-filter'
-import { captureServerEvent } from '@/lib/posthog/server'
+import {
+ performUploadKnowledgeDocument,
+ performUploadKnowledgeDocuments,
+} from '@/lib/knowledge/orchestration'
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
const logger = createLogger('DocumentsAPI')
@@ -210,168 +212,77 @@ export const POST = withRouteHandler(
)
}
- if (body.bulk === true) {
- const createdDocuments = await createDocumentRecords(
- body.documents,
- knowledgeBaseId,
- requestId,
- userId
- )
-
- logger.info(
- `[${requestId}] Starting controlled async processing of ${createdDocuments.length} documents`
- )
-
- try {
- const { PlatformEvents } = await import('@/lib/core/telemetry')
- PlatformEvents.knowledgeBaseDocumentsUploaded({
- knowledgeBaseId,
- documentsCount: createdDocuments.length,
- uploadType: 'bulk',
- recipe: body.processingOptions?.recipe,
- })
- } catch (_e) {
- // Silently fail
- }
-
- captureServerEvent(
- userId,
- 'knowledge_base_document_uploaded',
- {
- knowledge_base_id: knowledgeBaseId,
- workspace_id: kbWorkspaceId ?? '',
- document_count: createdDocuments.length,
- upload_type: 'bulk',
- },
- {
- ...(kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : {}),
- setOnce: { first_document_uploaded_at: new Date().toISOString() },
- }
- )
-
- processDocumentsWithQueue(
- createdDocuments,
- knowledgeBaseId,
- body.processingOptions ?? {},
- requestId,
- billingAttribution
- ).catch((error: unknown) => {
- logger.error(`[${requestId}] Critical error in document processing pipeline:`, error)
- })
+ const knowledgeBase = {
+ id: knowledgeBaseId,
+ name: accessCheck.knowledgeBase?.name,
+ workspaceId: kbWorkspaceId ?? null,
+ }
+ const actor = {
+ userId,
+ actorName: auth.userName,
+ actorEmail: auth.userEmail,
+ source: 'ui' as const,
+ requestId,
+ request: req,
+ }
- recordAudit({
- workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
- actorId: userId,
- actorName: auth.userName,
- actorEmail: auth.userEmail,
- action: AuditAction.DOCUMENT_UPLOADED,
- resourceType: AuditResourceType.DOCUMENT,
- resourceId: knowledgeBaseId,
- resourceName: `${createdDocuments.length} document(s)`,
- description: `Uploaded ${createdDocuments.length} document(s) to knowledge base "${knowledgeBaseId}"`,
- metadata: {
- knowledgeBaseName: accessCheck.knowledgeBase?.name,
- fileCount: createdDocuments.length,
- },
- request: req,
+ if (body.bulk === true) {
+ const outcome = await performUploadKnowledgeDocuments({
+ ...actor,
+ knowledgeBase,
+ documents: body.documents,
+ processingOptions: body.processingOptions,
+ billingAttribution,
})
+ if (!outcome.success) {
+ return NextResponse.json(
+ { error: messageForOrchestrationError(outcome, 'Failed to create document') },
+ { status: statusForOrchestrationError(outcome.errorCode) }
+ )
+ }
+ const { batchSize, maxConcurrentDocuments } = getProcessingConfig()
return NextResponse.json({
success: true,
data: {
- total: createdDocuments.length,
- documentsCreated: createdDocuments.map((doc) => ({
+ total: outcome.documents.length,
+ documentsCreated: outcome.documents.map((doc) => ({
documentId: doc.documentId,
filename: doc.filename,
status: 'pending',
})),
processingMethod: 'background',
processingConfig: {
- maxConcurrentDocuments: getProcessingConfig().maxConcurrentDocuments,
- batchSize: getProcessingConfig().batchSize,
- totalBatches: Math.ceil(createdDocuments.length / getProcessingConfig().batchSize),
+ maxConcurrentDocuments,
+ batchSize,
+ totalBatches: Math.ceil(outcome.documents.length / batchSize),
},
},
})
}
const { bulk: _bulk, workflowId: _workflowId, ...singleDocumentData } = body
- const newDocument = await createSingleDocument(
- singleDocumentData,
- knowledgeBaseId,
- requestId,
- userId
- )
-
- try {
- const { PlatformEvents } = await import('@/lib/core/telemetry')
- PlatformEvents.knowledgeBaseDocumentsUploaded({
- knowledgeBaseId,
- documentsCount: 1,
- uploadType: 'single',
- mimeType: singleDocumentData.mimeType,
- fileSize: singleDocumentData.fileSize,
- })
- } catch (_e) {
- // Silently fail
- }
-
- captureServerEvent(
- userId,
- 'knowledge_base_document_uploaded',
- {
- knowledge_base_id: knowledgeBaseId,
- workspace_id: kbWorkspaceId ?? '',
- document_count: 1,
- upload_type: 'single',
- },
- {
- ...(kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : {}),
- setOnce: { first_document_uploaded_at: new Date().toISOString() },
- }
- )
-
- recordAudit({
- workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
- actorId: userId,
- actorName: auth.userName,
- actorEmail: auth.userEmail,
- action: AuditAction.DOCUMENT_UPLOADED,
- resourceType: AuditResourceType.DOCUMENT,
- resourceId: knowledgeBaseId,
- resourceName: singleDocumentData.filename,
- description: `Uploaded document "${singleDocumentData.filename}" to knowledge base "${knowledgeBaseId}"`,
- metadata: {
- knowledgeBaseName: accessCheck.knowledgeBase?.name,
- fileName: singleDocumentData.filename,
- fileType: singleDocumentData.mimeType,
- fileSize: singleDocumentData.fileSize,
- },
- request: req,
- })
-
- return NextResponse.json({
- success: true,
- data: newDocument,
+ // Indexing is deliberately not started here: this path only records the
+ // document, and its caller drives processing separately.
+ const outcome = await performUploadKnowledgeDocument({
+ ...actor,
+ knowledgeBase,
+ document: singleDocumentData,
+ billingAttribution,
})
- } catch (error) {
- logger.error(`[${requestId}] Error creating document`, error)
-
- if (error instanceof KnowledgeBaseFileOwnershipError) {
+ if (!outcome.success) {
return NextResponse.json(
- { error: 'File URL does not reference a file owned by this knowledge base' },
- { status: 403 }
+ { error: messageForOrchestrationError(outcome, 'Failed to create document') },
+ { status: statusForOrchestrationError(outcome.errorCode) }
)
}
- const errorMessage = getErrorMessage(error, 'Failed to create document')
- const isStorageLimitError =
- errorMessage.includes('Storage limit exceeded') || errorMessage.includes('storage limit')
- const isMissingKnowledgeBase = errorMessage === 'Knowledge base not found'
-
+ return NextResponse.json({ success: true, data: outcome.document })
+ } catch (error) {
+ logger.error(`[${requestId}] Error creating document`, error)
return NextResponse.json(
- { error: errorMessage },
- { status: isMissingKnowledgeBase ? 404 : isStorageLimitError ? 413 : 500 }
+ { error: getErrorMessage(error, 'Failed to create document') },
+ { status: 500 }
)
}
}
diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts
new file mode 100644
index 00000000000..0c9b4be4b23
--- /dev/null
+++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts
@@ -0,0 +1,73 @@
+import { type NextRequest, NextResponse } from 'next/server'
+import { completeKnowledgeDocumentUploadContract } from '@/lib/api/contracts/knowledge/upload-sessions'
+import { parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { completeUploadSession } from '@/lib/uploads/upload-session/service'
+import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils'
+import {
+ requireKnowledgeDocumentUploadAccess,
+ requireKnowledgeDocumentUploadActor,
+ resolveKnowledgeDocumentUploadAttribution,
+} from '@/app/api/knowledge/[id]/documents/uploads/utils'
+import {
+ finalizeKnowledgeDocumentUpload,
+ getOwnedKnowledgeDocumentUpload,
+ toV2KnowledgeDocumentUpload,
+} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils'
+
+interface KnowledgeDocumentUploadRouteParams {
+ params: Promise<{ id: string; uploadId: string }>
+}
+
+export const POST = withRouteHandler(
+ async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => {
+ const actor = await requireKnowledgeDocumentUploadActor()
+ if (actor instanceof NextResponse) return actor
+ const parsed = await parseRequest(completeKnowledgeDocumentUploadContract, request, context)
+ if (!parsed.success) return parsed.response
+ const { id: knowledgeBaseId, uploadId } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+ const access = await requireKnowledgeDocumentUploadAccess({
+ knowledgeBaseId,
+ workspaceId,
+ userId: actor.id,
+ })
+ if (access instanceof NextResponse) return access
+ const requestId = generateRequestId()
+ try {
+ const upload = await getOwnedKnowledgeDocumentUpload({
+ knowledgeBaseId,
+ uploadId,
+ workspaceId,
+ userId: actor.id,
+ uploadToken: parsed.data.headers['upload-token'],
+ })
+ const completed = await completeUploadSession({
+ session: upload,
+ finalize: (claimed) =>
+ finalizeKnowledgeDocumentUpload({
+ claimed,
+ knowledgeBaseId,
+ knowledgeBaseName: access.knowledgeBase.name,
+ workspaceId,
+ userId: actor.id,
+ resolveAttribution: () =>
+ resolveKnowledgeDocumentUploadAttribution({ workspaceId, userId: actor.id }),
+ source: 'ui',
+ requestId,
+ request,
+ actorName: actor.name,
+ actorEmail: actor.email,
+ }),
+ })
+ return NextResponse.json({
+ data: toV2KnowledgeDocumentUpload(completed.session, completed.value),
+ })
+ } catch (error) {
+ const classified = uploadSessionErrorResponse(error)
+ if (classified) return classified
+ throw error
+ }
+ }
+)
diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts
new file mode 100644
index 00000000000..da327ab4703
--- /dev/null
+++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts
@@ -0,0 +1,55 @@
+import { type NextRequest, NextResponse } from 'next/server'
+import { createKnowledgeDocumentUploadPartUrlsContract } from '@/lib/api/contracts/knowledge/upload-sessions'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { createUploadPartUrls } from '@/lib/uploads/upload-session/service'
+import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils'
+import {
+ requireKnowledgeDocumentUploadAccess,
+ requireKnowledgeDocumentUploadActor,
+} from '@/app/api/knowledge/[id]/documents/uploads/utils'
+import { getOwnedKnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils'
+
+interface KnowledgeDocumentUploadRouteParams {
+ params: Promise<{ id: string; uploadId: string }>
+}
+
+export const POST = withRouteHandler(
+ async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => {
+ const actor = await requireKnowledgeDocumentUploadActor()
+ if (actor instanceof NextResponse) return actor
+ const parsed = await parseRequest(
+ createKnowledgeDocumentUploadPartUrlsContract,
+ request,
+ context
+ )
+ if (!parsed.success) return parsed.response
+ const { id: knowledgeBaseId, uploadId } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+ const access = await requireKnowledgeDocumentUploadAccess({
+ knowledgeBaseId,
+ workspaceId,
+ userId: actor.id,
+ })
+ if (access instanceof NextResponse) return access
+ try {
+ const upload = await getOwnedKnowledgeDocumentUpload({
+ knowledgeBaseId,
+ uploadId,
+ workspaceId,
+ userId: actor.id,
+ uploadToken: parsed.data.headers['upload-token'],
+ })
+ const parts = await createUploadPartUrls({
+ session: upload,
+ partNumbers: parsed.data.body.partNumbers,
+ localOrigin: request.nextUrl.origin,
+ })
+ return NextResponse.json({ data: { parts } })
+ } catch (error) {
+ const classified = uploadSessionErrorResponse(error)
+ if (classified) return classified
+ throw error
+ }
+ }
+)
diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts
new file mode 100644
index 00000000000..6a44d82d895
--- /dev/null
+++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts
@@ -0,0 +1,50 @@
+import { type NextRequest, NextResponse } from 'next/server'
+import { abortKnowledgeDocumentUploadContract } from '@/lib/api/contracts/knowledge/upload-sessions'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils'
+import {
+ requireKnowledgeDocumentUploadAccess,
+ requireKnowledgeDocumentUploadActor,
+} from '@/app/api/knowledge/[id]/documents/uploads/utils'
+import {
+ abortKnowledgeDocumentUpload,
+ getOwnedKnowledgeDocumentUpload,
+ toV2KnowledgeDocumentUpload,
+} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils'
+
+interface KnowledgeDocumentUploadRouteParams {
+ params: Promise<{ id: string; uploadId: string }>
+}
+
+export const DELETE = withRouteHandler(
+ async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => {
+ const actor = await requireKnowledgeDocumentUploadActor()
+ if (actor instanceof NextResponse) return actor
+ const parsed = await parseRequest(abortKnowledgeDocumentUploadContract, request, context)
+ if (!parsed.success) return parsed.response
+ const { id: knowledgeBaseId, uploadId } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+ const access = await requireKnowledgeDocumentUploadAccess({
+ knowledgeBaseId,
+ workspaceId,
+ userId: actor.id,
+ })
+ if (access instanceof NextResponse) return access
+ try {
+ const upload = await getOwnedKnowledgeDocumentUpload({
+ knowledgeBaseId,
+ uploadId,
+ workspaceId,
+ userId: actor.id,
+ uploadToken: parsed.data.headers['upload-token'],
+ })
+ const aborted = await abortKnowledgeDocumentUpload(upload, knowledgeBaseId)
+ return NextResponse.json({ data: toV2KnowledgeDocumentUpload(aborted, null) })
+ } catch (error) {
+ const classified = uploadSessionErrorResponse(error)
+ if (classified) return classified
+ throw error
+ }
+ }
+)
diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts
new file mode 100644
index 00000000000..ea79d0f4dc9
--- /dev/null
+++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts
@@ -0,0 +1,127 @@
+/**
+ * @vitest-environment node
+ */
+import { NextRequest, NextResponse } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCreateKnowledgeDocumentUploadSession,
+ mockRequireKnowledgeDocumentUploadAccess,
+ mockRequireKnowledgeDocumentUploadActor,
+ mockRequireKnowledgeDocumentUploadBilling,
+} = vi.hoisted(() => ({
+ mockCreateKnowledgeDocumentUploadSession: vi.fn(),
+ mockRequireKnowledgeDocumentUploadAccess: vi.fn(),
+ mockRequireKnowledgeDocumentUploadActor: vi.fn(),
+ mockRequireKnowledgeDocumentUploadBilling: vi.fn(),
+}))
+
+vi.mock('@/app/api/knowledge/[id]/documents/uploads/utils', () => ({
+ requireKnowledgeDocumentUploadAccess: mockRequireKnowledgeDocumentUploadAccess,
+ requireKnowledgeDocumentUploadActor: mockRequireKnowledgeDocumentUploadActor,
+ requireKnowledgeDocumentUploadBilling: mockRequireKnowledgeDocumentUploadBilling,
+}))
+vi.mock('@/app/api/files/uploads/utils', () => ({ uploadSessionErrorResponse: vi.fn() }))
+vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({
+ createKnowledgeDocumentUploadSession: mockCreateKnowledgeDocumentUploadSession,
+ toV2KnowledgeDocumentUpload: (session: Record) => ({
+ ...session,
+ name: session.fileName,
+ contentType: session.contentType,
+ size: session.fileSize,
+ expiresAt: '2026-08-05T00:00:00.000Z',
+ document: null,
+ }),
+}))
+
+import { POST } from '@/app/api/knowledge/[id]/documents/uploads/route'
+
+const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8'
+
+function request() {
+ return POST(
+ new NextRequest('http://localhost:3000/api/knowledge/kb-1/documents/uploads', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ workspaceId: WORKSPACE_ID,
+ name: 'guide.pdf',
+ contentType: 'application/pdf',
+ size: 1024,
+ tag1: 'product',
+ processingOptions: { recipe: 'default', lang: 'en' },
+ }),
+ }),
+ { params: Promise.resolve({ id: 'kb-1' }) }
+ )
+}
+
+describe('POST /api/knowledge/[id]/documents/uploads', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockRequireKnowledgeDocumentUploadActor.mockResolvedValue({ id: 'user-1' })
+ mockRequireKnowledgeDocumentUploadAccess.mockResolvedValue({
+ knowledgeBase: { id: 'kb-1', name: 'Docs', workspaceId: WORKSPACE_ID },
+ })
+ mockRequireKnowledgeDocumentUploadBilling.mockResolvedValue({ actorUserId: 'user-1' })
+ mockCreateKnowledgeDocumentUploadSession.mockResolvedValue({
+ id: 'upload-1',
+ knowledgeBaseId: 'kb-1',
+ status: 'uploading',
+ fileName: 'guide.pdf',
+ contentType: 'application/pdf',
+ fileSize: 1024,
+ uploadToken: 'token',
+ error: null,
+ transfer: {
+ method: 'put',
+ url: 'https://storage.example/upload',
+ headers: { 'content-type': 'application/pdf' },
+ },
+ })
+ })
+
+ it('authorizes and bills before allocating a first-party upload session', async () => {
+ const response = await request()
+
+ expect(response.status).toBe(201)
+ expect(mockRequireKnowledgeDocumentUploadAccess).toHaveBeenCalledWith({
+ knowledgeBaseId: 'kb-1',
+ workspaceId: WORKSPACE_ID,
+ userId: 'user-1',
+ })
+ expect(mockCreateKnowledgeDocumentUploadSession).toHaveBeenCalledWith({
+ workspaceId: WORKSPACE_ID,
+ userId: 'user-1',
+ knowledgeBaseId: 'kb-1',
+ fileName: 'guide.pdf',
+ contentType: 'application/pdf',
+ fileSize: 1024,
+ metadata: {
+ tag1: 'product',
+ processingOptions: { recipe: 'default', lang: 'en' },
+ },
+ localOrigin: 'http://localhost:3000',
+ })
+ expect((await response.json()).data).toMatchObject({
+ session: { id: 'upload-1', status: 'uploading', document: null },
+ uploadToken: 'token',
+ transfer: { method: 'put', url: 'https://storage.example/upload' },
+ })
+ expect(mockRequireKnowledgeDocumentUploadBilling.mock.invocationCallOrder[0]).toBeLessThan(
+ mockCreateKnowledgeDocumentUploadSession.mock.invocationCallOrder[0]
+ )
+ })
+
+ it('does not bill or allocate storage when write access is denied', async () => {
+ mockRequireKnowledgeDocumentUploadAccess.mockResolvedValue(
+ NextResponse.json({ error: 'Forbidden' }, { status: 403 })
+ )
+
+ const response = await request()
+
+ expect(response.status).toBe(403)
+ expect(mockRequireKnowledgeDocumentUploadBilling).not.toHaveBeenCalled()
+ expect(mockCreateKnowledgeDocumentUploadSession).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts
new file mode 100644
index 00000000000..58a1c69c253
--- /dev/null
+++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts
@@ -0,0 +1,71 @@
+import { type NextRequest, NextResponse } from 'next/server'
+import { createKnowledgeDocumentUploadContract } from '@/lib/api/contracts/knowledge/upload-sessions'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { validateFileType } from '@/lib/uploads/utils/validation'
+import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils'
+import {
+ requireKnowledgeDocumentUploadAccess,
+ requireKnowledgeDocumentUploadActor,
+ requireKnowledgeDocumentUploadBilling,
+} from '@/app/api/knowledge/[id]/documents/uploads/utils'
+import {
+ createKnowledgeDocumentUploadSession,
+ toV2KnowledgeDocumentUpload,
+} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils'
+
+interface KnowledgeDocumentUploadsRouteParams {
+ params: Promise<{ id: string }>
+}
+
+export const POST = withRouteHandler(
+ async (request: NextRequest, context: KnowledgeDocumentUploadsRouteParams) => {
+ const actor = await requireKnowledgeDocumentUploadActor()
+ if (actor instanceof NextResponse) return actor
+ const parsed = await parseRequest(createKnowledgeDocumentUploadContract, request, context)
+ if (!parsed.success) return parsed.response
+ const { id: knowledgeBaseId } = parsed.data.params
+ const { workspaceId, name, contentType, size, ...metadata } = parsed.data.body
+ const access = await requireKnowledgeDocumentUploadAccess({
+ knowledgeBaseId,
+ workspaceId,
+ userId: actor.id,
+ })
+ if (access instanceof NextResponse) return access
+ const billing = await requireKnowledgeDocumentUploadBilling({
+ workspaceId,
+ userId: actor.id,
+ })
+ if (billing instanceof NextResponse) return billing
+ const fileTypeError = validateFileType(name, contentType)
+ if (fileTypeError) {
+ return NextResponse.json({ error: fileTypeError.message }, { status: 415 })
+ }
+ try {
+ const upload = await createKnowledgeDocumentUploadSession({
+ workspaceId,
+ userId: actor.id,
+ knowledgeBaseId,
+ fileName: name,
+ contentType,
+ fileSize: size,
+ metadata,
+ localOrigin: request.nextUrl.origin,
+ })
+ return NextResponse.json(
+ {
+ data: {
+ session: toV2KnowledgeDocumentUpload(upload, null),
+ uploadToken: upload.uploadToken,
+ transfer: upload.transfer,
+ },
+ },
+ { status: 201 }
+ )
+ } catch (error) {
+ const classified = uploadSessionErrorResponse(error)
+ if (classified) return classified
+ throw error
+ }
+ }
+)
diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/utils.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/utils.ts
new file mode 100644
index 00000000000..450b17ecd0b
--- /dev/null
+++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/utils.ts
@@ -0,0 +1,73 @@
+import { NextResponse } from 'next/server'
+import { getSession } from '@/lib/auth'
+import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution'
+import {
+ checkAttributedUsageLimits,
+ resolveBillingAttribution,
+} from '@/lib/billing/core/billing-attribution'
+import type { KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils'
+import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
+
+export interface KnowledgeDocumentUploadActor {
+ id: string
+ name?: string | null
+ email?: string | null
+}
+
+export async function requireKnowledgeDocumentUploadActor(): Promise<
+ KnowledgeDocumentUploadActor | NextResponse
+> {
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+ return {
+ id: session.user.id,
+ name: session.user.name,
+ email: session.user.email,
+ }
+}
+
+export async function requireKnowledgeDocumentUploadAccess(params: {
+ knowledgeBaseId: string
+ workspaceId: string
+ userId: string
+}): Promise<{ knowledgeBase: KnowledgeBaseAccessResult['knowledgeBase'] } | NextResponse> {
+ const access = await checkKnowledgeBaseWriteAccess(params.knowledgeBaseId, params.userId)
+ if (!access.hasAccess) {
+ return 'notFound' in access && access.notFound
+ ? NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
+ : NextResponse.json({ error: 'Forbidden' }, { status: 403 })
+ }
+ if (access.knowledgeBase.workspaceId !== params.workspaceId) {
+ return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
+ }
+ return { knowledgeBase: access.knowledgeBase }
+}
+
+export async function requireKnowledgeDocumentUploadBilling(params: {
+ workspaceId: string
+ userId: string
+}): Promise {
+ const attribution = await resolveKnowledgeDocumentUploadAttribution(params)
+ const usage = await checkAttributedUsageLimits(attribution)
+ if (usage.isExceeded) {
+ return NextResponse.json(
+ {
+ error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.',
+ },
+ { status: 402 }
+ )
+ }
+ return attribution
+}
+
+export function resolveKnowledgeDocumentUploadAttribution(params: {
+ workspaceId: string
+ userId: string
+}): Promise {
+ return resolveBillingAttribution({
+ actorUserId: params.userId,
+ workspaceId: params.workspaceId,
+ })
+}
diff --git a/apps/sim/app/api/knowledge/[id]/restore/route.ts b/apps/sim/app/api/knowledge/[id]/restore/route.ts
index 5dee08582a6..a5ed8b85808 100644
--- a/apps/sim/app/api/knowledge/[id]/restore/route.ts
+++ b/apps/sim/app/api/knowledge/[id]/restore/route.ts
@@ -4,6 +4,10 @@ import { type NextRequest, NextResponse } from 'next/server'
import { restoreKnowledgeBaseContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import {
+ messageForOrchestrationError,
+ statusForOrchestrationError,
+} from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
@@ -45,12 +49,17 @@ export const POST = withRouteHandler(
const result = await performRestoreKnowledgeBase({
knowledgeBaseId: id,
userId: auth.userId,
+ actorName: auth.userName,
+ actorEmail: auth.userEmail,
+ source: 'ui',
requestId,
+ request,
})
if (!result.success) {
- const status =
- result.errorCode === 'not_found' ? 404 : result.errorCode === 'conflict' ? 409 : 500
- return NextResponse.json({ error: result.error }, { status })
+ return NextResponse.json(
+ { error: messageForOrchestrationError(result, 'Failed to restore knowledge base') },
+ { status: statusForOrchestrationError(result.errorCode) }
+ )
}
logger.info(`[${requestId}] Restored knowledge base ${id}`)
diff --git a/apps/sim/app/api/knowledge/[id]/route.ts b/apps/sim/app/api/knowledge/[id]/route.ts
index cd47c173ab4..3b91289af20 100644
--- a/apps/sim/app/api/knowledge/[id]/route.ts
+++ b/apps/sim/app/api/knowledge/[id]/route.ts
@@ -1,20 +1,19 @@
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { updateKnowledgeBaseContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
-import { PlatformEvents } from '@/lib/core/telemetry'
+import {
+ messageForOrchestrationError,
+ statusForOrchestrationError,
+} from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
- deleteKnowledgeBase,
- getKnowledgeBaseById,
- KnowledgeBaseConflictError,
- KnowledgeBaseFolderError,
- KnowledgeBasePermissionError,
- updateKnowledgeBase,
-} from '@/lib/knowledge/service'
+ performDeleteKnowledgeBase,
+ performUpdateKnowledgeBase,
+} from '@/lib/knowledge/orchestration'
+import { getKnowledgeBaseById } from '@/lib/knowledge/service'
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
const logger = createLogger('KnowledgeBaseByIdAPI')
@@ -69,93 +68,56 @@ export const PUT = withRouteHandler(
const requestId = generateRequestId()
const { id } = await context.params
- try {
- const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
- if (!auth.success || !auth.userId) {
- logger.warn(`[${requestId}] Unauthorized knowledge base update attempt`)
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
- const userId = auth.userId
+ const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized knowledge base update attempt`)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+ const userId = auth.userId
- const accessCheck = await checkKnowledgeBaseWriteAccess(id, userId)
+ const accessCheck = await checkKnowledgeBaseWriteAccess(id, userId)
- if (!accessCheck.hasAccess) {
- if ('notFound' in accessCheck && accessCheck.notFound) {
- logger.warn(`[${requestId}] Knowledge base not found: ${id}`)
- return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
- }
- logger.warn(
- `[${requestId}] User ${userId} attempted to update unauthorized knowledge base ${id}`
- )
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ if (!accessCheck.hasAccess) {
+ if ('notFound' in accessCheck && accessCheck.notFound) {
+ logger.warn(`[${requestId}] Knowledge base not found: ${id}`)
+ return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
}
-
- const parsed = await parseRequest(updateKnowledgeBaseContract, req, context)
- if (!parsed.success) return parsed.response
-
- const validatedData = parsed.data.body
-
- const updatedKnowledgeBase = await updateKnowledgeBase(
- id,
- {
- name: validatedData.name,
- description: validatedData.description,
- workspaceId: validatedData.workspaceId,
- folderId: validatedData.folderId,
- chunkingConfig: validatedData.chunkingConfig,
- },
- requestId,
- { actorUserId: userId }
+ logger.warn(
+ `[${requestId}] User ${userId} attempted to update unauthorized knowledge base ${id}`
)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
- logger.info(`[${requestId}] Knowledge base updated: ${id} for user ${userId}`)
-
- recordAudit({
- workspaceId: accessCheck.knowledgeBase.workspaceId ?? null,
- actorId: userId,
- actorName: auth.userName,
- actorEmail: auth.userEmail,
- action: AuditAction.KNOWLEDGE_BASE_UPDATED,
- resourceType: AuditResourceType.KNOWLEDGE_BASE,
- resourceId: id,
- resourceName: validatedData.name ?? updatedKnowledgeBase.name,
- description: `Updated knowledge base "${validatedData.name ?? updatedKnowledgeBase.name}"`,
- metadata: {
- updatedFields: Object.keys(validatedData).filter(
- (k) => validatedData[k as keyof typeof validatedData] !== undefined
- ),
- ...(validatedData.name && { newName: validatedData.name }),
- ...(validatedData.description !== undefined && {
- description: validatedData.description,
- }),
- ...(validatedData.chunkingConfig && {
- chunkMaxSize: validatedData.chunkingConfig.maxSize,
- chunkMinSize: validatedData.chunkingConfig.minSize,
- chunkOverlap: validatedData.chunkingConfig.overlap,
- }),
- },
- request: req,
- })
-
- return NextResponse.json({
- success: true,
- data: updatedKnowledgeBase,
- })
- } catch (error) {
- if (error instanceof KnowledgeBaseConflictError) {
- return NextResponse.json({ error: error.message }, { status: 409 })
- }
- if (error instanceof KnowledgeBaseFolderError) {
- return NextResponse.json({ error: error.message }, { status: 400 })
- }
- if (error instanceof KnowledgeBasePermissionError) {
- logger.warn(`[${requestId}] Forbidden knowledge base update on ${id}: ${error.message}`)
- return NextResponse.json({ error: error.message }, { status: 403 })
- }
-
- logger.error(`[${requestId}] Error updating knowledge base`, error)
- return NextResponse.json({ error: 'Failed to update knowledge base' }, { status: 500 })
+ const parsed = await parseRequest(updateKnowledgeBaseContract, req, context)
+ if (!parsed.success) return parsed.response
+
+ const body = parsed.data.body
+
+ const outcome = await performUpdateKnowledgeBase({
+ knowledgeBaseId: id,
+ workspaceId: accessCheck.knowledgeBase.workspaceId ?? null,
+ userId,
+ actorName: auth.userName,
+ actorEmail: auth.userEmail,
+ source: 'ui',
+ updates: {
+ name: body.name,
+ description: body.description,
+ workspaceId: body.workspaceId,
+ folderId: body.folderId,
+ chunkingConfig: body.chunkingConfig,
+ },
+ requestId,
+ request: req,
+ })
+ if (!outcome.success) {
+ return NextResponse.json(
+ { error: messageForOrchestrationError(outcome, 'Failed to update knowledge base') },
+ { status: statusForOrchestrationError(outcome.errorCode) }
+ )
}
+
+ return NextResponse.json({ success: true, data: outcome.knowledgeBase })
}
)
@@ -164,62 +126,49 @@ export const DELETE = withRouteHandler(
const requestId = generateRequestId()
const { id } = await params
- try {
- const auth = await checkSessionOrInternalAuth(_request, { requireWorkflowId: false })
- if (!auth.success || !auth.userId) {
- logger.warn(`[${requestId}] Unauthorized knowledge base delete attempt`)
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
- const userId = auth.userId
-
- const accessCheck = await checkKnowledgeBaseWriteAccess(id, userId)
-
- if (!accessCheck.hasAccess) {
- if ('notFound' in accessCheck && accessCheck.notFound) {
- logger.warn(`[${requestId}] Knowledge base not found: ${id}`)
- return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
- }
- logger.warn(
- `[${requestId}] User ${userId} attempted to delete unauthorized knowledge base ${id}`
- )
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
+ const auth = await checkSessionOrInternalAuth(_request, { requireWorkflowId: false })
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized knowledge base delete attempt`)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+ const userId = auth.userId
- await deleteKnowledgeBase(id, requestId)
+ const accessCheck = await checkKnowledgeBaseWriteAccess(id, userId)
- try {
- PlatformEvents.knowledgeBaseDeleted({
- knowledgeBaseId: id,
- })
- } catch {
- // Telemetry should not fail the operation
+ if (!accessCheck.hasAccess) {
+ if ('notFound' in accessCheck && accessCheck.notFound) {
+ logger.warn(`[${requestId}] Knowledge base not found: ${id}`)
+ return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
}
+ logger.warn(
+ `[${requestId}] User ${userId} attempted to delete unauthorized knowledge base ${id}`
+ )
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
- logger.info(`[${requestId}] Knowledge base deleted: ${id} for user ${userId}`)
-
- recordAudit({
+ const outcome = await performDeleteKnowledgeBase({
+ knowledgeBase: {
+ id,
+ name: accessCheck.knowledgeBase.name,
workspaceId: accessCheck.knowledgeBase.workspaceId ?? null,
- actorId: userId,
- actorName: auth.userName,
- actorEmail: auth.userEmail,
- action: AuditAction.KNOWLEDGE_BASE_DELETED,
- resourceType: AuditResourceType.KNOWLEDGE_BASE,
- resourceId: id,
- resourceName: accessCheck.knowledgeBase.name,
- description: `Deleted knowledge base "${accessCheck.knowledgeBase.name || id}"`,
- metadata: {
- knowledgeBaseName: accessCheck.knowledgeBase.name,
- },
- request: _request,
- })
-
- return NextResponse.json({
- success: true,
- data: { message: 'Knowledge base deleted successfully' },
- })
- } catch (error) {
- logger.error(`[${requestId}] Error deleting knowledge base`, error)
- return NextResponse.json({ error: 'Failed to delete knowledge base' }, { status: 500 })
+ },
+ userId,
+ actorName: auth.userName,
+ actorEmail: auth.userEmail,
+ source: 'ui',
+ requestId,
+ request: _request,
+ })
+ if (!outcome.success) {
+ return NextResponse.json(
+ { error: messageForOrchestrationError(outcome, 'Failed to delete knowledge base') },
+ { status: statusForOrchestrationError(outcome.errorCode) }
+ )
}
+
+ return NextResponse.json({
+ success: true,
+ data: { message: 'Knowledge base deleted successfully' },
+ })
}
)
diff --git a/apps/sim/app/api/knowledge/route.ts b/apps/sim/app/api/knowledge/route.ts
index b2f9177b49d..09178f9dff1 100644
--- a/apps/sim/app/api/knowledge/route.ts
+++ b/apps/sim/app/api/knowledge/route.ts
@@ -1,4 +1,3 @@
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import {
@@ -7,19 +6,14 @@ import {
} from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
-import { PlatformEvents } from '@/lib/core/telemetry'
+import {
+ messageForOrchestrationError,
+ statusForOrchestrationError,
+} from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings'
-import {
- createKnowledgeBase,
- getKnowledgeBases,
- KnowledgeBaseConflictError,
- KnowledgeBaseFolderError,
- KnowledgeBasePermissionError,
- type KnowledgeBaseScope,
-} from '@/lib/knowledge/service'
-import { captureServerEvent } from '@/lib/posthog/server'
+import { performCreateKnowledgeBase } from '@/lib/knowledge/orchestration'
+import { getKnowledgeBases, type KnowledgeBaseScope } from '@/lib/knowledge/service'
const logger = createLogger('KnowledgeBaseAPI')
@@ -65,113 +59,49 @@ export const GET = withRouteHandler(async (req: NextRequest) => {
export const POST = withRouteHandler(async (req: NextRequest) => {
const requestId = generateRequestId()
- try {
- const session = await getSession()
- if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized knowledge base creation attempt`)
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const parsed = await parseRequest(
- createKnowledgeBaseContract,
- req,
- {},
- {
- validationErrorResponse: (error) => {
- logger.warn(`[${requestId}] Invalid knowledge base data`, { errors: error.issues })
- return NextResponse.json(
- { error: 'Invalid request data', details: error.issues },
- { status: 400 }
- )
- },
- }
- )
- if (!parsed.success) return parsed.response
-
- const validatedData = parsed.data.body
-
- try {
- const embeddingModel = getConfiguredEmbeddingModel()
-
- const createData = {
- ...validatedData,
- userId: session.user.id,
- embeddingModel,
- embeddingDimension: EMBEDDING_DIMENSIONS,
- }
-
- const newKnowledgeBase = await createKnowledgeBase(createData, requestId)
-
- try {
- PlatformEvents.knowledgeBaseCreated({
- knowledgeBaseId: newKnowledgeBase.id,
- name: validatedData.name,
- workspaceId: validatedData.workspaceId,
- })
- } catch {
- // Telemetry should not fail the operation
- }
-
- captureServerEvent(
- session.user.id,
- 'knowledge_base_created',
- {
- knowledge_base_id: newKnowledgeBase.id,
- workspace_id: validatedData.workspaceId,
- name: validatedData.name,
- },
- {
- groups: { workspace: validatedData.workspaceId },
- setOnce: { first_kb_created_at: new Date().toISOString() },
- }
- )
-
- logger.info(
- `[${requestId}] Knowledge base created: ${newKnowledgeBase.id} for user ${session.user.id}`
- )
-
- recordAudit({
- workspaceId: validatedData.workspaceId,
- actorId: session.user.id,
- actorName: session.user.name,
- actorEmail: session.user.email,
- action: AuditAction.KNOWLEDGE_BASE_CREATED,
- resourceType: AuditResourceType.KNOWLEDGE_BASE,
- resourceId: newKnowledgeBase.id,
- resourceName: validatedData.name,
- description: `Created knowledge base "${validatedData.name}"`,
- metadata: {
- name: validatedData.name,
- description: validatedData.description,
- embeddingModel,
- embeddingDimension: EMBEDDING_DIMENSIONS,
- chunkingStrategy: validatedData.chunkingConfig.strategy,
- chunkMaxSize: validatedData.chunkingConfig.maxSize,
- chunkMinSize: validatedData.chunkingConfig.minSize,
- chunkOverlap: validatedData.chunkingConfig.overlap,
- },
- request: req,
- })
+ const session = await getSession()
+ if (!session?.user?.id) {
+ logger.warn(`[${requestId}] Unauthorized knowledge base creation attempt`)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
- return NextResponse.json({
- success: true,
- data: newKnowledgeBase,
- })
- } catch (createError) {
- if (createError instanceof KnowledgeBaseConflictError) {
- return NextResponse.json({ error: createError.message }, { status: 409 })
- }
- if (createError instanceof KnowledgeBaseFolderError) {
- return NextResponse.json({ error: createError.message }, { status: 400 })
- }
- if (createError instanceof KnowledgeBasePermissionError) {
- logger.warn(`[${requestId}] Forbidden knowledge base creation: ${createError.message}`)
- return NextResponse.json({ error: createError.message }, { status: 403 })
- }
- throw createError
+ const parsed = await parseRequest(
+ createKnowledgeBaseContract,
+ req,
+ {},
+ {
+ validationErrorResponse: (error) => {
+ logger.warn(`[${requestId}] Invalid knowledge base data`, { errors: error.issues })
+ return NextResponse.json(
+ { error: 'Invalid request data', details: error.issues },
+ { status: 400 }
+ )
+ },
}
- } catch (error) {
- logger.error(`[${requestId}] Error creating knowledge base`, error)
- return NextResponse.json({ error: 'Failed to create knowledge base' }, { status: 500 })
+ )
+ if (!parsed.success) return parsed.response
+
+ const body = parsed.data.body
+
+ const outcome = await performCreateKnowledgeBase({
+ userId: session.user.id,
+ actorName: session.user.name,
+ actorEmail: session.user.email,
+ source: 'ui',
+ workspaceId: body.workspaceId,
+ name: body.name,
+ description: body.description,
+ folderId: body.folderId,
+ chunkingConfig: body.chunkingConfig,
+ requestId,
+ request: req,
+ })
+ if (!outcome.success) {
+ return NextResponse.json(
+ { error: messageForOrchestrationError(outcome, 'Failed to create knowledge base') },
+ { status: statusForOrchestrationError(outcome.errorCode) }
+ )
}
+
+ return NextResponse.json({ success: true, data: outcome.knowledgeBase })
})
diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts
index 99bf39603b6..837aec3a24b 100644
--- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts
+++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts
@@ -16,12 +16,14 @@ import { NextRequest } from 'next/server'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
+ mockExecuteWorkflowService,
mockAssertBillingAttributionSnapshot,
mockGenerateInternalToken,
mockResolveBillingAttribution,
mockSerializeBillingAttributionHeader,
fetchMock,
} = vi.hoisted(() => ({
+ mockExecuteWorkflowService: vi.fn(),
mockAssertBillingAttributionSnapshot: vi.fn(),
mockGenerateInternalToken: vi.fn(),
mockResolveBillingAttribution: vi.fn(),
@@ -65,6 +67,10 @@ vi.mock('@/lib/core/execution-limits', () => ({
getMaxExecutionTimeout: () => 10_000,
}))
+vi.mock('@/lib/workflows/executor/execute-service', () => ({
+ executeWorkflowService: mockExecuteWorkflowService,
+}))
+
import { DELETE, GET, POST } from '@/app/api/mcp/serve/[serverId]/route'
describe('MCP Serve Route', () => {
@@ -230,7 +236,7 @@ describe('MCP Serve Route', () => {
expect(response.status).toBe(401)
})
- it('uses an internal bridge token for private server api_key auth', async () => {
+ it('executes in-process with the personal-key actor override for private server api_key auth', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([
{
@@ -251,13 +257,16 @@ describe('MCP Serve Route', () => {
apiKeyType: 'personal',
})
mockGetUserEntityPermissions.mockResolvedValueOnce('write')
- mockGenerateInternalToken.mockResolvedValueOnce('internal-token-user-1')
- fetchMock.mockResolvedValueOnce(
- new Response(JSON.stringify({ output: { ok: true } }), {
- status: 200,
- headers: { 'Content-Type': 'application/json' },
- })
- )
+ mockExecuteWorkflowService.mockResolvedValueOnce({
+ ok: true,
+ executionId: 'exec-1',
+ workflowId: 'wf-1',
+ status: 'completed',
+ aborted: null,
+ output: { ok: true },
+ error: null,
+ hasResponseBlock: false,
+ })
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
@@ -272,21 +281,25 @@ describe('MCP Serve Route', () => {
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
expect(response.status).toBe(200)
- expect(fetchMock).toHaveBeenCalledTimes(1)
- const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit
- const headers = fetchOptions.headers as Record
- expect(headers.Authorization).toBe('Bearer internal-token-user-1')
- expect(headers['X-Sim-MCP-Tool-Actor']).toBe('authenticated-user')
- expect(headers['x-sim-billing-attribution']).toBe('serialized-attribution')
- expect(headers['X-API-Key']).toBeUndefined()
- expect(mockGenerateInternalToken).toHaveBeenCalledWith('user-1')
+ expect(mockExecuteWorkflowService).toHaveBeenCalledTimes(1)
+ expect(mockExecuteWorkflowService).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workflowId: 'wf-1',
+ userId: 'user-1',
+ triggerType: 'mcp',
+ useAuthenticatedUserAsActor: true,
+ deploymentVersionId: 'deployment-1',
+ includeFileBase64: false,
+ rejectLargeInlineOutput: true,
+ })
+ )
expect(mockResolveBillingAttribution).toHaveBeenCalledWith({
actorUserId: 'user-1',
workspaceId: 'ws-1',
})
})
- it('forwards internal token for private server session auth', async () => {
+ it('executes in-process without the actor override for private server session auth', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([
{
@@ -306,13 +319,16 @@ describe('MCP Serve Route', () => {
authType: 'session',
})
mockGetUserEntityPermissions.mockResolvedValueOnce('read')
- mockGenerateInternalToken.mockResolvedValueOnce('internal-token-user-1')
- fetchMock.mockResolvedValueOnce(
- new Response(JSON.stringify({ output: { ok: true } }), {
- status: 200,
- headers: { 'Content-Type': 'application/json' },
- })
- )
+ mockExecuteWorkflowService.mockResolvedValueOnce({
+ ok: true,
+ executionId: 'exec-1',
+ workflowId: 'wf-1',
+ status: 'completed',
+ aborted: null,
+ output: { ok: true },
+ error: null,
+ hasResponseBlock: false,
+ })
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
@@ -326,14 +342,12 @@ describe('MCP Serve Route', () => {
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
expect(response.status).toBe(200)
- expect(fetchMock).toHaveBeenCalledTimes(1)
- const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit
- const headers = fetchOptions.headers as Record
- expect(headers.Authorization).toBe('Bearer internal-token-user-1')
- expect(headers['X-Sim-MCP-Tool-Actor']).toBeUndefined()
- expect(headers['x-sim-billing-attribution']).toBe('serialized-attribution')
- expect(headers['X-API-Key']).toBeUndefined()
- expect(mockGenerateInternalToken).toHaveBeenCalledWith('user-1')
+ expect(mockExecuteWorkflowService).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId: 'user-1',
+ useAuthenticatedUserAsActor: false,
+ })
+ )
expect(mockResolveBillingAttribution).toHaveBeenCalledWith({
actorUserId: 'user-1',
workspaceId: 'ws-1',
@@ -353,13 +367,16 @@ describe('MCP Serve Route', () => {
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
.mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }])
- mockGenerateInternalToken.mockResolvedValueOnce('internal-token-owner-1')
- fetchMock.mockResolvedValueOnce(
- new Response(JSON.stringify({ output: { ok: true } }), {
- status: 200,
- headers: { 'Content-Type': 'application/json' },
- })
- )
+ mockExecuteWorkflowService.mockResolvedValueOnce({
+ ok: true,
+ executionId: 'exec-1',
+ workflowId: 'wf-1',
+ status: 'completed',
+ aborted: null,
+ output: { ok: true },
+ error: null,
+ hasResponseBlock: false,
+ })
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
@@ -380,12 +397,9 @@ describe('MCP Serve Route', () => {
})
const attribution = createBillingAttribution('owner-1', 'ws-1')
expect(mockAssertBillingAttributionSnapshot).toHaveBeenCalledWith(attribution)
- expect(mockSerializeBillingAttributionHeader).toHaveBeenCalledWith(attribution)
- const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit
- const headers = fetchOptions.headers as Record
- expect(headers.Authorization).toBe('Bearer internal-token-owner-1')
- expect(headers['x-sim-billing-attribution']).toBe('serialized-attribution')
- expect(headers['x-sim-billing-attribution']).not.toBe('caller-controlled-attribution')
+ expect(mockExecuteWorkflowService).toHaveBeenCalledWith(
+ expect.objectContaining({ upstreamBillingAttribution: attribution, userId: 'owner-1' })
+ )
})
it.each([null, 'ws-other'])(
@@ -545,8 +559,7 @@ describe('MCP Serve Route', () => {
expect(fetchMock).not.toHaveBeenCalled()
})
- it('cancels and rejects oversized workflow execution responses', async () => {
- const cancelSpy = vi.fn()
+ it('maps oversized workflow outputs to the response-direction 413', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([
{
@@ -559,17 +572,17 @@ describe('MCP Serve Route', () => {
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
.mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }])
- fetchMock.mockResolvedValueOnce(
- new Response(
- new ReadableStream({
- cancel: cancelSpy,
- }),
- {
- status: 200,
- headers: { 'content-length': String(MCP_BYTE_LIMIT + 1) },
- }
- )
- )
+
+ mockExecuteWorkflowService.mockResolvedValueOnce({
+ ok: false,
+ failure: {
+ kind: 'output_too_large',
+ statusCode: 413,
+ message: 'Workflow execution response exceeds maximum size',
+ code: 'workflow_response_too_large',
+ executionId: 'exec-1',
+ },
+ })
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
@@ -580,17 +593,19 @@ describe('MCP Serve Route', () => {
params: { name: 'tool_a', arguments: { q: 'test' } },
}),
})
-
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
+
const body = await response.json()
expect(response.status).toBe(413)
- expect(body.error.message).toContain('MCP workflow execution response')
- expect(cancelSpy).toHaveBeenCalled()
+ expect(body.error.data.httpStatus).toBe(413)
+ // Response-direction 413 keeps the workflow_response_too_large code so
+ // clients can distinguish it from a request-side payload rejection.
+ expect(body.error.data.code).toBe('workflow_response_too_large')
+ expect(body.error.data.executionId).toBe('exec-1')
})
- it('cancels and rejects streamed workflow responses that exceed the cap', async () => {
- const cancelSpy = vi.fn()
+ it('surfaces rate-limit failures with Retry-After and the retryable flag', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([
{
@@ -603,21 +618,17 @@ describe('MCP Serve Route', () => {
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
.mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }])
- fetchMock.mockResolvedValueOnce(
- new Response(
- new ReadableStream({
- start(controller) {
- controller.enqueue(new Uint8Array(MCP_BYTE_LIMIT))
- controller.enqueue(new Uint8Array(1))
- },
- cancel: cancelSpy,
- }),
- {
- status: 200,
- headers: { 'content-length': '1' },
- }
- )
- )
+
+ mockExecuteWorkflowService.mockResolvedValueOnce({
+ ok: false,
+ failure: {
+ kind: 'precheck',
+ statusCode: 429,
+ message: 'Rate limit exceeded. Please try again later.',
+ code: 'RATE_LIMIT_EXCEEDED',
+ retryAfterMs: 9_000,
+ },
+ })
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
@@ -628,13 +639,14 @@ describe('MCP Serve Route', () => {
params: { name: 'tool_a', arguments: { q: 'test' } },
}),
})
-
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
+
const body = await response.json()
- expect(response.status).toBe(413)
- expect(body.error.message).toContain('MCP workflow execution response')
- expect(cancelSpy).toHaveBeenCalled()
+ expect(response.status).toBe(429)
+ expect(response.headers.get('Retry-After')).toBe('9')
+ expect(body.error.data.retryable).toBe(true)
+ expect(body.error.data.code).toBe('RATE_LIMIT_EXCEEDED')
})
it('preserves recoverable workflow execution statuses through the MCP bridge', async () => {
@@ -650,18 +662,15 @@ describe('MCP Serve Route', () => {
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
.mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }])
- fetchMock.mockResolvedValueOnce(
- new Response(
- JSON.stringify({
- success: false,
- error: 'Workflow execution request body exceeds maximum size',
- }),
- {
- status: 413,
- headers: { 'Content-Type': 'application/json' },
- }
- )
- )
+
+ mockExecuteWorkflowService.mockResolvedValueOnce({
+ ok: false,
+ failure: {
+ kind: 'infra',
+ statusCode: 503,
+ message: 'Error checking rate limits',
+ },
+ })
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
@@ -672,19 +681,13 @@ describe('MCP Serve Route', () => {
params: { name: 'tool_a', arguments: { q: 'test' } },
}),
})
-
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
+
const body = await response.json()
- expect(response.status).toBe(413)
- expect(body.error.code).toBe(-32600)
- expect(body.error.data.httpStatus).toBe(413)
- const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit
- const headers = fetchOptions.headers as Record
- expect(headers['X-Sim-MCP-Tool-Call']).toBe('true')
- expect(JSON.parse(fetchOptions.body as string)).toMatchObject({
- deploymentVersionId: 'deployment-1',
- })
+ expect(response.status).toBe(503)
+ expect(body.error.data.httpStatus).toBe(503)
+ expect(body.error.data.retryable).toBe(true)
})
it('preserves downstream attributed usage admission rejections', async () => {
@@ -700,18 +703,15 @@ describe('MCP Serve Route', () => {
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
.mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }])
- fetchMock.mockResolvedValueOnce(
- new Response(
- JSON.stringify({
- success: false,
- error: 'Workspace usage limit exceeded.',
- }),
- {
- status: 402,
- headers: { 'Content-Type': 'application/json' },
- }
- )
- )
+
+ mockExecuteWorkflowService.mockResolvedValueOnce({
+ ok: false,
+ failure: {
+ kind: 'precheck',
+ statusCode: 402,
+ message: 'Workspace usage limit exceeded.',
+ },
+ })
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
@@ -722,16 +722,16 @@ describe('MCP Serve Route', () => {
params: { name: 'tool_a' },
}),
})
-
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
+
const body = await response.json()
expect(response.status).toBe(402)
- expect(body.error.message).toBe('Workspace usage limit exceeded.')
expect(body.error.data.httpStatus).toBe(402)
+ expect(body.error.message).toBe('Workspace usage limit exceeded.')
})
- it('preserves upstream error status when workflow response is not JSON', async () => {
+ it('maps the sync timeout onto the retryable 408 shape', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([
{
@@ -744,7 +744,17 @@ describe('MCP Serve Route', () => {
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
.mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }])
- fetchMock.mockResolvedValueOnce(new Response('gateway timeout', { status: 408 }))
+
+ mockExecuteWorkflowService.mockResolvedValueOnce({
+ ok: true,
+ executionId: 'exec-1',
+ workflowId: 'wf-1',
+ status: 'failed',
+ aborted: 'timeout',
+ output: undefined,
+ error: { message: 'Execution timed out after 60000ms', code: 'TIMEOUT' },
+ hasResponseBlock: false,
+ })
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
@@ -755,13 +765,14 @@ describe('MCP Serve Route', () => {
params: { name: 'tool_a', arguments: { q: 'test' } },
}),
})
-
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
+
const body = await response.json()
expect(response.status).toBe(408)
expect(body.error.data.httpStatus).toBe(408)
expect(body.error.data.retryable).toBe(true)
+ expect(body.error.data.code).toBe('TIMEOUT')
})
it('preserves falsy workflow outputs in MCP tool results', async () => {
@@ -777,12 +788,17 @@ describe('MCP Serve Route', () => {
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
.mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }])
- fetchMock.mockResolvedValueOnce(
- new Response(JSON.stringify({ success: true, output: false }), {
- status: 200,
- headers: { 'Content-Type': 'application/json' },
- })
- )
+
+ mockExecuteWorkflowService.mockResolvedValueOnce({
+ ok: true,
+ executionId: 'exec-1',
+ workflowId: 'wf-1',
+ status: 'completed',
+ aborted: null,
+ output: false,
+ error: null,
+ hasResponseBlock: false,
+ })
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
@@ -793,15 +809,16 @@ describe('MCP Serve Route', () => {
params: { name: 'tool_a', arguments: { q: 'test' } },
}),
})
-
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
+
const body = await response.json()
expect(response.status).toBe(200)
expect(body.result.content[0].text).toBe('false')
+ expect(body.result.isError).toBe(false)
})
- it('serializes missing workflow output without failing the MCP tool call', async () => {
+ it('serializes failed runs with the structured error and child executionId', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([
{
@@ -814,12 +831,23 @@ describe('MCP Serve Route', () => {
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
.mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }])
- fetchMock.mockResolvedValueOnce(
- new Response(JSON.stringify({ success: true }), {
- status: 200,
- headers: { 'Content-Type': 'application/json' },
- })
- )
+
+ mockExecuteWorkflowService.mockResolvedValueOnce({
+ ok: true,
+ executionId: 'exec-9',
+ workflowId: 'wf-1',
+ status: 'failed',
+ aborted: null,
+ output: { partial: true },
+ error: {
+ message: 'Invalid credentials',
+ code: 'BLOCK_EXECUTION_FAILED',
+ blockId: 'b-1',
+ blockName: 'Send Email',
+ blockType: 'gmail',
+ },
+ hasResponseBlock: false,
+ })
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
@@ -830,27 +858,32 @@ describe('MCP Serve Route', () => {
params: { name: 'tool_a', arguments: { q: 'test' } },
}),
})
-
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
+
const body = await response.json()
expect(response.status).toBe(200)
- expect(body.result.content[0].text).toContain('"success": true')
+ expect(body.result.isError).toBe(true)
+ const text = body.result.content[0].text
+ expect(text).toContain('"executionId": "exec-9"')
+ expect(text).toContain('"code": "BLOCK_EXECUTION_FAILED"')
+ expect(text).toContain('"blockName": "Send Email"')
})
- it('serializes non-object workflow JSON responses from response blocks', async () => {
+ it('serializes non-object workflow outputs', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([
{
id: 'server-1',
- name: 'Private Server',
+ name: 'Public Server',
workspaceId: 'ws-1',
- isPublic: false,
+ isPublic: true,
createdBy: 'owner-1',
},
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
.mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }])
+
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
userId: 'user-1',
@@ -858,12 +891,16 @@ describe('MCP Serve Route', () => {
apiKeyType: 'personal',
})
mockGetUserEntityPermissions.mockResolvedValueOnce('write')
- fetchMock.mockResolvedValueOnce(
- new Response(JSON.stringify(['a', 'b']), {
- status: 200,
- headers: { 'Content-Type': 'application/json' },
- })
- )
+ mockExecuteWorkflowService.mockResolvedValueOnce({
+ ok: true,
+ executionId: 'exec-1',
+ workflowId: 'wf-1',
+ status: 'completed',
+ aborted: null,
+ output: ['a', 'b'],
+ error: null,
+ hasResponseBlock: false,
+ })
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
@@ -875,12 +912,12 @@ describe('MCP Serve Route', () => {
params: { name: 'tool_a', arguments: { q: 'test' } },
}),
})
-
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
+
const body = await response.json()
expect(response.status).toBe(200)
- expect(body.result.content[0].text).toBe(JSON.stringify(['a', 'b'], null, 2))
+ expect(JSON.parse(body.result.content[0].text)).toEqual(['a', 'b'])
})
it('rejects duplicate tool names instead of choosing an arbitrary workflow', async () => {
@@ -917,8 +954,7 @@ describe('MCP Serve Route', () => {
expect(fetchMock).not.toHaveBeenCalled()
})
- it('aborts the internal workflow fetch when the MCP client disconnects', async () => {
- const requestAbortController = new AbortController()
+ it('maps a client-aborted run onto ConnectionClosed', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([
{
@@ -931,36 +967,34 @@ describe('MCP Serve Route', () => {
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
.mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }])
- fetchMock.mockImplementationOnce((_url, init: RequestInit) => {
- const signal = init.signal as AbortSignal
- return new Promise((_resolve, reject) => {
- signal.addEventListener(
- 'abort',
- () => {
- reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))
- },
- { once: true }
- )
- requestAbortController.abort()
- })
- })
- const req = new NextRequest(
- new Request('http://localhost:3000/api/mcp/serve/server-1', {
- method: 'POST',
- body: JSON.stringify({
- jsonrpc: '2.0',
- id: 1,
- method: 'tools/call',
- params: { name: 'tool_a', arguments: { q: 'test' } },
- }),
- signal: requestAbortController.signal,
- })
- )
+ mockExecuteWorkflowService.mockResolvedValueOnce({
+ ok: true,
+ executionId: 'exec-1',
+ workflowId: 'wf-1',
+ status: 'cancelled',
+ aborted: 'client',
+ output: undefined,
+ error: { message: 'Client cancelled request', code: 'CANCELLED' },
+ hasResponseBlock: false,
+ })
+ const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
+ method: 'POST',
+ body: JSON.stringify({
+ jsonrpc: '2.0',
+ id: 1,
+ method: 'tools/call',
+ params: { name: 'tool_a', arguments: { q: 'test' } },
+ }),
+ })
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
+ const body = await response.json()
+
expect(response.status).toBe(499)
+ expect(body.error.data.httpStatus).toBe(499)
+ expect(body.error.data.executionId).toBe('exec-1')
})
it('paginates tools/list by tool count', async () => {
diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts
index e342f35e343..a60436ec6cf 100644
--- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts
+++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts
@@ -35,34 +35,29 @@ import {
mcpToolCallParamsSchema,
} from '@/lib/api/contracts/mcp'
import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid'
-import { generateInternalToken } from '@/lib/auth/internal'
import {
assertBillingAttributionSnapshot,
- BILLING_ATTRIBUTION_HEADER,
type BillingAttributionSnapshot,
resolveBillingAttribution,
- serializeBillingAttributionHeader,
} from '@/lib/billing/core/billing-attribution'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
+import { generateRequestId } from '@/lib/core/utils/request'
import {
assertContentLengthWithinLimit,
assertKnownSizeWithinLimit,
isPayloadSizeLimitError,
- readResponseTextWithLimit,
readStreamToBufferWithLimit,
} from '@/lib/core/utils/stream-limits'
-import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { SIM_VIA_HEADER } from '@/lib/execution/call-chain'
+import { parseCallChain, SIM_VIA_HEADER } from '@/lib/execution/call-chain'
import {
MAX_MCP_PARAMETER_SCHEMA_BYTES,
MAX_MCP_TOOLS_LIST_RESPONSE_BYTES,
MAX_MCP_TOOLS_PER_SERVER,
MAX_MCP_WORKFLOW_RESPONSE_BYTES,
- MCP_TOOL_BRIDGE_ACTOR_HEADER,
- MCP_TOOL_BRIDGE_HEADER,
} from '@/lib/mcp/constants'
import { getMeaningfulWorkflowDescription } from '@/lib/mcp/workflow-tool-schema'
+import { executeWorkflowService } from '@/lib/workflows/executor/execute-service'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkflowMcpServeAPI')
@@ -281,21 +276,6 @@ async function getDuplicateToolName(serverId: string): Promise {
return duplicate?.toolName ?? null
}
-async function readWorkflowExecutionResult(
- response: Response,
- signal: AbortSignal
-): Promise {
- const text = await readResponseTextWithLimit(response, {
- maxBytes: MAX_MCP_WORKFLOW_RESPONSE_BYTES,
- label: 'MCP workflow execution response',
- signal,
- })
- const parsed = parseJsonValue(text)
- if (parsed.success) return parsed.value
- if (!response.ok) return { error: response.statusText || 'Workflow execution failed' }
- throw new Error('Invalid workflow execution response')
-}
-
async function getServer(serverId: string) {
const [server] = await db
.select({
@@ -809,83 +789,113 @@ async function handleToolsCall(
wf.workspaceId
)
- const executeUrl = `${getInternalApiBaseUrl()}/api/workflows/${tool.workflowId}/execute`
- const headers: Record = {
- 'Content-Type': 'application/json',
- [BILLING_ATTRIBUTION_HEADER]: serializeBillingAttributionHeader(billingAttribution),
- [MCP_TOOL_BRIDGE_HEADER]: 'true',
- }
-
const abortedBeforeExecute = callerAbortedJsonRpcResponse(id, abortSignal)
if (abortedBeforeExecute) return abortedBeforeExecute
- const internalToken = await generateInternalToken(actorUserId)
- headers.Authorization = `Bearer ${internalToken}`
- if (executeAuthContext?.useAuthenticatedUserAsActor) {
- headers[MCP_TOOL_BRIDGE_ACTOR_HEADER] = 'authenticated-user'
- }
-
- if (simViaHeader) {
- headers[SIM_VIA_HEADER] = simViaHeader
- }
-
- logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name}`)
+ logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name} (in-process)`)
- const workflowRequestBody = JSON.stringify({
- input: params.arguments || {},
- triggerType: 'mcp',
- includeFileBase64: false,
- ...(wf.deploymentVersionId ? { deploymentVersionId: wf.deploymentVersionId } : {}),
- })
+ const workflowInput = params.arguments || {}
assertKnownSizeWithinLimit(
- Buffer.byteLength(workflowRequestBody, 'utf-8'),
+ Buffer.byteLength(JSON.stringify(workflowInput), 'utf-8'),
MAX_MCP_WORKFLOW_REQUEST_BYTES,
'MCP workflow execution request body'
)
- const response = await fetch(executeUrl, {
- method: 'POST',
- headers,
- body: workflowRequestBody,
- signal: abortSignal.signal,
- })
- const executeResult = await readWorkflowExecutionResult(response, abortSignal.signal)
- const executeResultObject = isJsonObject(executeResult) ? executeResult : null
+ /**
+ * In-process execution replaces the historical HTTP hop to the execute
+ * endpoint: the bridge's special needs — deployment-version pinning, MCP
+ * response-size rejection, actor override — are typed options instead of
+ * header sniffing, and billing attribution is passed as the immutable
+ * upstream snapshot exactly as the header carried it.
+ */
+ const serviceResult = await executeWorkflowService({
+ workflowId: tool.workflowId,
+ userId: actorUserId,
+ input: workflowInput,
+ triggerType: 'mcp',
+ requestId: generateRequestId(),
+ useAuthenticatedUserAsActor: executeAuthContext?.useAuthenticatedUserAsActor ?? false,
+ upstreamBillingAttribution: billingAttribution,
+ deploymentVersionId: wf.deploymentVersionId,
+ includeFileBase64: false,
+ rejectLargeInlineOutput: true,
+ callChain: simViaHeader ? parseCallChain(simViaHeader) : undefined,
+ abortSignal: abortSignal.signal,
+ })
- if (!response.ok) {
- const errorMessage =
- typeof executeResultObject?.error === 'string'
- ? executeResultObject.error
- : 'Workflow execution failed'
- const status = getWorkflowErrorStatus(response.status)
+ if (!serviceResult.ok) {
+ const failure = serviceResult.failure
+ const status = getWorkflowErrorStatus(failure.statusCode)
const responseHeaders: Record = {}
- const retryAfter = response.headers.get('retry-after')
- if (retryAfter) responseHeaders['Retry-After'] = retryAfter
+ if (failure.retryAfterMs !== undefined) {
+ responseHeaders['Retry-After'] = Math.max(
+ 1,
+ Math.ceil(failure.retryAfterMs / 1000)
+ ).toString()
+ }
return NextResponse.json(
createError(
id,
- getWorkflowErrorCode(response.status, executeResultObject ?? {}),
- errorMessage,
+ getWorkflowErrorCode(failure.statusCode, { code: failure.code }),
+ failure.message,
{
- httpStatus: response.status,
- retryable: [408, 429, 503].includes(response.status),
- code:
- typeof executeResultObject?.code === 'string' ? executeResultObject.code : undefined,
+ httpStatus: failure.statusCode,
+ retryable: [408, 429, 503].includes(failure.statusCode),
+ code: failure.code,
+ ...(failure.executionId ? { executionId: failure.executionId } : {}),
}
),
{ status, headers: responseHeaders }
)
}
- const toolOutput =
- executeResultObject?.success === false
- ? executeResult
- : executeResultObject && hasResponseField(executeResultObject, 'output')
- ? executeResultObject.output
- : executeResult
+ if ('queued' in serviceResult || 'stream' in serviceResult) {
+ // The bridge never requests async or stream modes.
+ throw new Error('Unexpected execution mode result for MCP tool call')
+ }
+
+ if (serviceResult.aborted === 'client') {
+ return NextResponse.json(
+ createError(id, ErrorCode.ConnectionClosed, 'Client cancelled request', {
+ httpStatus: 499,
+ retryable: false,
+ executionId: serviceResult.executionId,
+ }),
+ { status: 499 }
+ )
+ }
+
+ if (serviceResult.aborted === 'timeout') {
+ return NextResponse.json(
+ createError(
+ id,
+ ErrorCode.InternalError,
+ serviceResult.error?.message ?? 'Execution timed out',
+ {
+ httpStatus: 408,
+ retryable: true,
+ code: 'TIMEOUT',
+ executionId: serviceResult.executionId,
+ }
+ ),
+ { status: 408 }
+ )
+ }
+
+ const isError = serviceResult.status !== 'completed'
+ const toolOutput = isError
+ ? {
+ success: false,
+ executionId: serviceResult.executionId,
+ output: serviceResult.output ?? {},
+ // Structured error: parents/clients route on `code` and hand the
+ // provider the executionId to reproduce the failure.
+ error: serviceResult.error,
+ }
+ : (serviceResult.output ?? {})
const result: CallToolResult = {
content: [{ type: 'text', text: serializeToolText(toolOutput) }],
- isError: executeResultObject?.success === false,
+ isError,
}
return createJsonRpcResponseWithLimit(
diff --git a/apps/sim/app/api/skills/route.ts b/apps/sim/app/api/skills/route.ts
index f31f0881e71..251635a37fb 100644
--- a/apps/sim/app/api/skills/route.ts
+++ b/apps/sim/app/api/skills/route.ts
@@ -1,4 +1,3 @@
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import {
@@ -10,10 +9,14 @@ import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { captureServerEvent } from '@/lib/posthog/server'
-import { checkSkillsUpdateAccess, getSkillActorContext } from '@/lib/skills/access'
+import {
+ performCreateSkill,
+ performDeleteSkill,
+ performUpdateSkill,
+ statusForSkillOrchestrationError,
+} from '@/lib/skills/orchestration'
import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills'
-import { deleteSkill, listSkillsForUser, upsertSkills } from '@/lib/workflows/skills/operations'
+import { listSkillsForUser } from '@/lib/workflows/skills/operations'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('SkillsAPI')
@@ -92,84 +95,75 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
- if (skills.some((s) => s.id && isBuiltinSkillId(s.id))) {
- return NextResponse.json({ error: 'Built-in skills are read-only' }, { status: 400 })
+ /**
+ * Each item is applied through the skill orchestration, which owns the
+ * built-in guard, the field limits, the per-skill editor check, and the
+ * audit. Creating still requires workspace write; editing an existing skill
+ * is gated per skill inside `performUpdateSkill`.
+ *
+ * The batch is applied item by item rather than in one transaction: this
+ * endpoint's callers submit a single skill, and one shared authority for the
+ * rules is worth more than atomicity across a batch nobody sends.
+ */
+ const actor = {
+ actorName: authResult.userName,
+ actorEmail: authResult.userEmail,
+ source,
+ request: req,
}
- // Updating an existing skill requires editor access (explicit editor row
- // or derived workspace admin); creating a new one requires workspace write.
- const requestedIds = skills.flatMap((s) => (s.id ? [s.id] : []))
- const { existingIds, denied } = await checkSkillsUpdateAccess({
- workspaceId,
- userId,
- skillIds: requestedIds,
- workspaceAccess,
- })
-
- if (denied.length > 0) {
- logger.warn(`[${requestId}] User ${userId} is not an editor of skills being updated`, {
- deniedSkillIds: denied.map((s) => s.id),
- })
- return NextResponse.json(
- {
- error: `Skill editor access required to update: ${denied.map((s) => s.name).join(', ')}`,
- },
- { status: 403 }
- )
- }
+ for (const item of skills) {
+ if (item.id) {
+ const result = await performUpdateSkill({
+ workspaceId,
+ userId,
+ skillId: item.id,
+ name: item.name,
+ description: item.description,
+ content: item.content,
+ ...actor,
+ })
+ if (!result.success) {
+ logger.warn(`[${requestId}] Skill update rejected`, {
+ skillId: item.id,
+ errorCode: result.errorCode,
+ })
+ return NextResponse.json(
+ { error: result.error ?? 'Failed to update skill' },
+ { status: statusForSkillOrchestrationError(result.errorCode) }
+ )
+ }
+ continue
+ }
- const hasCreates = skills.some((s) => !s.id || !existingIds.has(s.id))
- if (hasCreates && !workspaceAccess.canWrite) {
- logger.warn(
- `[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}`
- )
- return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
- }
+ if (!workspaceAccess.canWrite) {
+ logger.warn(
+ `[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}`
+ )
+ return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
+ }
- try {
- const { touched } = await upsertSkills({
- skills,
+ const result = await performCreateSkill({
workspaceId,
userId,
- requestId,
- returnSkills: false,
+ name: item.name!,
+ description: item.description!,
+ content: item.content!,
+ ...actor,
})
-
- for (const { id, name, operation } of touched) {
- const isUpdate = operation === 'updated'
- recordAudit({
- workspaceId,
- actorId: userId,
- actorName: authResult.userName ?? undefined,
- actorEmail: authResult.userEmail ?? undefined,
- action: isUpdate ? AuditAction.SKILL_UPDATED : AuditAction.SKILL_CREATED,
- resourceType: AuditResourceType.SKILL,
- resourceId: id,
- resourceName: name,
- description: `${isUpdate ? 'Updated' : 'Created'} skill "${name}"`,
- metadata: { source },
- })
- captureServerEvent(
- userId,
- isUpdate ? 'skill_updated' : 'skill_created',
- { skill_id: id, skill_name: name, workspace_id: workspaceId, source },
- { groups: { workspace: workspaceId } }
+ if (!result.success) {
+ logger.warn(`[${requestId}] Skill create rejected`, { errorCode: result.errorCode })
+ return NextResponse.json(
+ { error: result.error ?? 'Failed to create skill' },
+ { status: statusForSkillOrchestrationError(result.errorCode) }
)
}
+ }
- const resultSkills = await listSkillsForUser({ workspaceId, userId, workspaceAccess })
- const data = resultSkills.map((s) => ({ ...s, readOnly: isBuiltinSkillId(s.id) }))
+ const resultSkills = await listSkillsForUser({ workspaceId, userId, workspaceAccess })
+ const data = resultSkills.map((s) => ({ ...s, readOnly: isBuiltinSkillId(s.id) }))
- return NextResponse.json({ success: true, data })
- } catch (upsertError) {
- if (upsertError instanceof Error && upsertError.message.includes('is unavailable')) {
- return NextResponse.json({ error: upsertError.message }, { status: 409 })
- }
- if (upsertError instanceof Error && upsertError.message.startsWith('Skill not found')) {
- return NextResponse.json({ error: 'Skill not found' }, { status: 404 })
- }
- throw upsertError
- }
+ return NextResponse.json({ success: true, data })
} catch (error) {
logger.error(`[${requestId}] Error updating skills`, error)
return NextResponse.json({ error: 'Failed to update skills' }, { status: 500 })
@@ -200,42 +194,25 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => {
}
const { id: skillId, workspaceId, source } = query.data
- if (!isBuiltinSkillId(skillId)) {
- const actor = await getSkillActorContext(skillId, userId)
- if (!actor.skill || actor.skill.workspaceId !== workspaceId || !actor.hasWorkspaceAccess) {
- logger.warn(`[${requestId}] Skill not found: ${skillId}`)
- return NextResponse.json({ error: 'Skill not found' }, { status: 404 })
- }
- if (!actor.canEdit) {
- logger.warn(`[${requestId}] User ${userId} is not an editor of skill ${skillId}`)
- return NextResponse.json({ error: 'Skill editor access required' }, { status: 403 })
- }
- }
-
- const deleted = await deleteSkill({ skillId, workspaceId })
- if (!deleted) {
- logger.warn(`[${requestId}] Skill not found: ${skillId}`)
- return NextResponse.json({ error: 'Skill not found' }, { status: 404 })
- }
-
- recordAudit({
+ const result = await performDeleteSkill({
workspaceId,
- actorId: authResult.userId,
- actorName: authResult.userName ?? undefined,
- actorEmail: authResult.userEmail ?? undefined,
- action: AuditAction.SKILL_DELETED,
- resourceType: AuditResourceType.SKILL,
- resourceId: skillId,
- description: `Deleted skill`,
- metadata: { source },
- })
-
- captureServerEvent(
userId,
- 'skill_deleted',
- { skill_id: skillId, workspace_id: workspaceId, source },
- { groups: { workspace: workspaceId } }
- )
+ skillId,
+ actorName: authResult.userName,
+ actorEmail: authResult.userEmail,
+ source,
+ request,
+ })
+ if (!result.success) {
+ logger.warn(`[${requestId}] Skill delete rejected`, {
+ skillId,
+ errorCode: result.errorCode,
+ })
+ return NextResponse.json(
+ { error: result.error ?? 'Failed to delete skill' },
+ { status: statusForSkillOrchestrationError(result.errorCode) }
+ )
+ }
logger.info(`[${requestId}] Deleted skill: ${skillId}`)
return NextResponse.json({ success: true })
diff --git a/apps/sim/app/api/table/[tableId]/columns/route.test.ts b/apps/sim/app/api/table/[tableId]/columns/route.test.ts
index 4ac282861cd..e8497a0fa04 100644
--- a/apps/sim/app/api/table/[tableId]/columns/route.test.ts
+++ b/apps/sim/app/api/table/[tableId]/columns/route.test.ts
@@ -42,6 +42,13 @@ vi.mock('@/lib/table', () => ({
updateColumnOptions: mockUpdateColumnOptions,
updateColumnType: mockUpdateColumnType,
}))
+vi.mock('@/lib/table/columns/service', () => ({
+ renameColumn: mockRenameColumn,
+ updateColumnConstraints: mockUpdateColumnConstraints,
+ updateColumnCurrency: mockUpdateColumnCurrency,
+ updateColumnOptions: mockUpdateColumnOptions,
+ updateColumnType: mockUpdateColumnType,
+}))
vi.mock('@/app/api/table/utils', () => ({
accessError: () => new Response('denied', { status: 403 }),
checkAccess: mockCheckAccess,
@@ -50,6 +57,7 @@ vi.mock('@/app/api/table/utils', () => ({
tableLockErrorResponse: () => null,
}))
+import { OrchestrationError } from '@/lib/core/orchestration/types'
import { PATCH } from '@/app/api/table/[tableId]/columns/route'
const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'
@@ -159,7 +167,10 @@ describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => {
// Stands in for the race the guards cannot close: the column stopped being
// a currency between the snapshot the guards read and this write.
mockUpdateColumnCurrency.mockRejectedValue(
- new Error('Cannot set currency on column "amount" of type "string"')
+ new OrchestrationError(
+ 'validation',
+ 'Cannot set currency on column "amount" of type "string"'
+ )
)
const response = await patch({ name: 'renamed', currencyCode: 'USD' })
diff --git a/apps/sim/app/api/table/[tableId]/columns/route.ts b/apps/sim/app/api/table/[tableId]/columns/route.ts
index abda376ede7..d899372d2be 100644
--- a/apps/sim/app/api/table/[tableId]/columns/route.ts
+++ b/apps/sim/app/api/table/[tableId]/columns/route.ts
@@ -8,21 +8,12 @@ import {
import { parseRequest } from '@/lib/api/server'
import { isZodError, validationErrorResponse } from '@/lib/api/server/validation'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import { statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import {
- addTableColumn,
- deleteColumn,
- renameColumn,
- updateColumnConstraints,
- updateColumnCurrency,
- updateColumnOptions,
- updateColumnType,
-} from '@/lib/table'
-import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys'
-import { columnTypeById } from '@/lib/table/column-types'
-import { isSupportedCurrencyCode } from '@/lib/table/currency'
+import { addTableColumn, deleteColumn } from '@/lib/table'
import { signalTableSchemaChanged } from '@/lib/table/events'
+import { performUpdateTableColumn } from '@/lib/table/orchestration'
import {
accessError,
checkAccess,
@@ -122,216 +113,35 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
- const { updates } = validated
- let updatedTable = null
-
- // A payload that repeats the current type must not go through
- // `updateColumnType` — it early-returns on an unchanged type and would drop
- // any `options` alongside it. Only a real type change routes there; an
- // unchanged type with options routes to the options-only update.
- const currentColumn = table.schema.columns.find((c) =>
- columnMatchesRef(c, validated.columnName)
- )
- // Address every write below by the stable id, not the name: a rename folded
- // into one of them must not break the next one's lookup.
- const columnRef = currentColumn ? getColumnId(currentColumn) : validated.columnName
- // The constraints write below is a separate, unconditional step, so it is
- // the last one whenever it runs — that is the write the rename rides on.
- const typeChanging = updates.type !== undefined && updates.type !== currentColumn?.type
- if (!currentColumn) {
- return NextResponse.json(
- { error: `Column "${validated.columnName}" not found` },
- { status: 404 }
- )
- }
-
- // A retype applies and validates the constraints itself, so the separate
- // constraint write only runs when the type is unchanged. The rename rides
- // whichever write actually runs last.
- const typedWriteRuns =
- typeChanging ||
- updates.currencyCode !== undefined ||
- updates.options !== undefined ||
- updates.multiple !== undefined
- const constraintsWriteRuns =
- !typedWriteRuns && (updates.required !== undefined || updates.unique !== undefined)
- const renameWithTypedWrite =
- updates.name && !constraintsWriteRuns ? { newName: updates.name } : {}
-
- // Every write below is its own locked transaction, so one that is going to
- // fail leaves the earlier ones committed. These guards reject the knowable
- // cases up front, before any write at all.
- // Gate on the type the column ENDS UP with, not on whether the type is
- // changing: an options-only update on an existing select column carries the
- // same hazard as a conversion does.
- const resultingType = updates.type ?? currentColumn?.type
- if (updates.currencyCode !== undefined) {
- if (resultingType !== 'currency') {
- return NextResponse.json(
- {
- error: `Cannot set currency on column "${validated.columnName}" of type "${resultingType}"`,
- },
- { status: 400 }
- )
- }
- if (!isSupportedCurrencyCode(updates.currencyCode)) {
- return NextResponse.json(
- {
- error: `Invalid currency code "${updates.currencyCode}". Use an ISO 4217 code, e.g. USD`,
- },
- { status: 400 }
- )
- }
- }
- // The rename runs last (see below), so a name already taken would fail after
- // the typed write committed. This is the only rename failure a caller can
- // cause; catching it here leaves just the concurrent-collision race, which
- // no pre-flight check can close.
- if (
- updates.name &&
- table.schema.columns.some(
- (c) =>
- c.name.toLowerCase() === updates.name?.toLowerCase() &&
- !columnMatchesRef(c, validated.columnName)
- )
- ) {
- return NextResponse.json(
- { error: `Column "${updates.name}" already exists` },
- { status: 400 }
- )
- }
- if (
- currentColumn?.workflowGroupId &&
- (updates.required !== undefined || updates.unique !== undefined)
- ) {
- return NextResponse.json(
- {
- error: `Cannot change constraints on workflow-output column "${currentColumn.name}". Constraints aren't applicable to columns whose values come from workflow execution.`,
- },
- { status: 400 }
- )
- }
- if (updates.unique === true && !columnTypeById(resultingType).supportsUnique) {
+ const outcome = await performUpdateTableColumn({
+ table,
+ columnName: validated.columnName,
+ userId: authResult.userId,
+ updates: validated.updates,
+ requestId,
+ request,
+ })
+ if (!outcome.success || !outcome.table) {
return NextResponse.json(
- { error: `Cannot set a ${resultingType} column as unique` },
- { status: 400 }
- )
- }
-
- if (typeChanging) {
- updatedTable = await updateColumnType(
- {
- tableId,
- columnName: columnRef,
- newType: updates.type as NonNullable,
- ...(updates.options !== undefined ? { options: updates.options } : {}),
- ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}),
- ...(updates.currencyCode !== undefined ? { currencyCode: updates.currencyCode } : {}),
- // Forwarded so the conversion validates against the constraint this
- // same request is about to set, not the column's current one.
- ...(updates.required !== undefined ? { required: updates.required } : {}),
- ...(updates.unique !== undefined ? { unique: updates.unique } : {}),
- ...renameWithTypedWrite,
- },
- requestId
- )
- } else if (updates.currencyCode !== undefined) {
- // Re-denominating an existing currency column: schema-only, no cell
- // rewrite. Reached only when the type is unchanged — a conversion INTO
- // currency carries the code through `updateColumnType` above.
- updatedTable = await updateColumnCurrency(
- {
- tableId,
- columnName: columnRef,
- currencyCode: updates.currencyCode,
- ...(updates.required !== undefined ? { required: updates.required } : {}),
- ...(updates.unique !== undefined ? { unique: updates.unique } : {}),
- ...renameWithTypedWrite,
- },
- requestId
- )
- } else if (updates.options !== undefined || updates.multiple !== undefined) {
- updatedTable = await updateColumnOptions(
- {
- tableId,
- columnName: columnRef,
- options: updates.options ?? currentColumn?.options ?? [],
- ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}),
- // Forwarded so the removal guard validates against the constraint this
- // same request is about to set, not the column's current one.
- ...(updates.required !== undefined ? { required: updates.required } : {}),
- ...(updates.unique !== undefined ? { unique: updates.unique } : {}),
- ...renameWithTypedWrite,
- },
- requestId
- )
- }
-
- // Skipped whenever a typed write ran: that write already applied and
- // validated these, in one transaction with the change they accompany.
- if (constraintsWriteRuns) {
- updatedTable = await updateColumnConstraints(
- {
- tableId,
- columnName: columnRef,
- ...(updates.required !== undefined ? { required: updates.required } : {}),
- ...(updates.unique !== undefined ? { unique: updates.unique } : {}),
- ...(updates.name ? { newName: updates.name } : {}),
- },
- requestId
+ { error: outcome.error ?? 'Failed to update column' },
+ { status: statusForOrchestrationError(outcome.errorCode) }
)
}
- // A rename rides along with the LAST write above, inside that write's
- // transaction — a rename is metadata-only (rows key on the stable column
- // id), so nothing forces it to be its own write, and folding it in is what
- // stops a combined request from committing one half and then failing. Only
- // a rename with nothing to ride on runs standalone.
- if (updates.name && !updatedTable) {
- updatedTable = await renameColumn(
- { tableId, oldName: columnRef, newName: updates.name },
- requestId
- )
- }
-
- if (!updatedTable) {
- return NextResponse.json({ error: 'No updates specified' }, { status: 400 })
- }
+ // Live-collab: tell open viewers the change landed so they refetch.
signalTableSchemaChanged(tableId)
return NextResponse.json({
success: true,
data: {
- columns: updatedTable.schema.columns.map(normalizeColumn),
+ columns: outcome.table.schema.columns.map(normalizeColumn),
},
})
} catch (error) {
- const lockError = tableLockErrorResponse(error)
- if (lockError) return lockError
if (isZodError(error)) {
return validationErrorResponse(error, 'Invalid request data')
}
- const msg = rootErrorMessage(error)
- if (msg.includes('not found') || msg.includes('Table not found')) {
- return NextResponse.json({ error: msg }, { status: 404 })
- }
- if (
- msg.includes('already exists') ||
- msg.includes('Cannot delete the last column') ||
- msg.includes('Cannot set column') ||
- msg.includes('Cannot set unique column') ||
- msg.includes('Invalid column') ||
- msg.includes('exceeds maximum') ||
- msg.includes('incompatible') ||
- msg.includes('duplicate') ||
- msg.includes('option') ||
- msg.includes('currency') ||
- msg.includes('is already type')
- ) {
- return NextResponse.json({ error: msg }, { status: 400 })
- }
-
logger.error(`[${requestId}] Error updating column in table ${tableId}:`, error)
return NextResponse.json({ error: 'Failed to update column' }, { status: 500 })
}
diff --git a/apps/sim/app/api/table/[tableId]/columns/run/route.ts b/apps/sim/app/api/table/[tableId]/columns/run/route.ts
index e1927a204c1..e9140d22a83 100644
--- a/apps/sim/app/api/table/[tableId]/columns/run/route.ts
+++ b/apps/sim/app/api/table/[tableId]/columns/run/route.ts
@@ -9,7 +9,12 @@ import { TableQueryValidationError } from '@/lib/table/errors'
import { signalTableRowsChanged } from '@/lib/table/events'
import { toLegacyFilter } from '@/lib/table/query-builder/converters'
import { runWorkflowColumn } from '@/lib/table/workflow-columns'
-import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils'
+import {
+ accessError,
+ checkAccess,
+ orchestrationErrorResponse,
+ tableFilterError,
+} from '@/app/api/table/utils'
const logger = createLogger('TableRunColumnAPI')
@@ -74,9 +79,8 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
if (error instanceof TableQueryValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
- if (error instanceof Error && error.message === 'Invalid workspace ID') {
- return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
- }
+ const classified = orchestrationErrorResponse(error)
+ if (classified) return classified
logger.error(`run-column failed:`, error)
return NextResponse.json({ error: 'Failed to run columns' }, { status: 500 })
}
diff --git a/apps/sim/app/api/table/[tableId]/export/route.ts b/apps/sim/app/api/table/[tableId]/export/route.ts
index 58df047c629..17a845ed0ae 100644
--- a/apps/sim/app/api/table/[tableId]/export/route.ts
+++ b/apps/sim/app/api/table/[tableId]/export/route.ts
@@ -1,25 +1,18 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
-import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { tableExportFormatSchema, tableIdParamsSchema } from '@/lib/api/contracts/tables'
import { getValidationErrorMessage } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
-import { neutralizeCsvFormula } from '@/lib/core/utils/csv'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
-import { namedRowMapper } from '@/lib/table/cell-format'
-import { getColumnId } from '@/lib/table/column-keys'
-import { formatCsvCell } from '@/lib/table/export-format'
-import { queryRows } from '@/lib/table/rows/service'
+import {
+ createTableExportStream,
+ exportContentType,
+ sanitizeExportFilename,
+} from '@/lib/table/export-stream'
import { accessError, checkAccess } from '@/app/api/table/utils'
-const logger = createLogger('TableExport')
-
-const EXPORT_BATCH_SIZE = 1000
-
-type ExportFormat = 'csv' | 'json'
-
interface RouteParams {
params: Promise<{ tableId: string }>
}
@@ -45,19 +38,12 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou
{ status: 400 }
)
}
- const format: ExportFormat = formatValidation.data
+ const format = formatValidation.data
const access = await checkAccess(tableId, auth.userId, 'read')
if (!access.ok) return accessError(access, requestId, tableId)
const { table } = access
- const columns = table.schema.columns
- // Stored row data is id-keyed; CSV headers and JSON keys are display names, so
- // translate id → name on the way out (export is a name-friendly boundary).
- const toNamedRow = namedRowMapper(columns)
- const safeName = sanitizeFilename(table.name)
- const filename = `${safeName}.${format}`
-
// Audit before streaming: rows leave incrementally, so a mid-stream failure still exfiltrates partial data.
recordAudit({
workspaceId: table.workspaceId ?? null,
@@ -79,80 +65,12 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou
)
}
- const stream = new ReadableStream({
- async start(controller) {
- const encoder = new TextEncoder()
- try {
- if (format === 'csv') {
- controller.enqueue(
- encoder.encode(`${toCsvRow(columns.map((c) => neutralizeCsvFormula(c.name)))}\n`)
- )
- } else {
- controller.enqueue(encoder.encode('['))
- }
-
- let offset = 0
- let firstJsonRow = true
- while (true) {
- const result = await queryRows(
- table,
- { limit: EXPORT_BATCH_SIZE, offset, includeTotal: false },
- requestId
- )
-
- for (const row of result.rows) {
- if (format === 'csv') {
- const values = columns.map((c) => formatCsvCell(c, row.data[getColumnId(c)]))
- controller.enqueue(encoder.encode(`${toCsvRow(values)}\n`))
- } else {
- const prefix = firstJsonRow ? '' : ','
- firstJsonRow = false
- controller.enqueue(encoder.encode(prefix + JSON.stringify(toNamedRow(row.data))))
- }
- }
-
- // A page can be cut by the byte budget before reaching EXPORT_BATCH_SIZE,
- // so a short page does NOT mean the export is done — only a null cursor does.
- if (!result.nextCursor) break
- offset += result.rows.length
- }
-
- if (format === 'json') controller.enqueue(encoder.encode(']'))
- controller.close()
-
- logger.info(`[${requestId}] Exported table ${tableId}`, {
- format,
- rowCount: table.rowCount,
- })
- } catch (err) {
- logger.error(`[${requestId}] Export failed for table ${tableId}`, err)
- controller.error(err)
- }
- },
- })
-
- return new NextResponse(stream, {
+ return new NextResponse(createTableExportStream(table, format, requestId), {
status: 200,
headers: {
- 'Content-Type': format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json',
- 'Content-Disposition': `attachment; filename="${filename}"`,
+ 'Content-Type': exportContentType(format),
+ 'Content-Disposition': `attachment; filename="${sanitizeExportFilename(table.name)}.${format}"`,
'Cache-Control': 'no-store',
},
})
})
-
-function sanitizeFilename(name: string): string {
- const cleaned = name.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '')
- return cleaned || 'table'
-}
-
-function toCsvRow(values: string[]): string {
- return values.map(escapeCsvField).join(',')
-}
-
-function escapeCsvField(field: string): string {
- if (/[",\n\r]/.test(field)) {
- return `"${field.replace(/"/g, '""')}"`
- }
- return field
-}
diff --git a/apps/sim/app/api/table/[tableId]/exports/route.ts b/apps/sim/app/api/table/[tableId]/exports/route.ts
new file mode 100644
index 00000000000..525f455b81b
--- /dev/null
+++ b/apps/sim/app/api/table/[tableId]/exports/route.ts
@@ -0,0 +1,39 @@
+import { type NextRequest, NextResponse } from 'next/server'
+import { createTableExportResourceContract } from '@/lib/api/contracts/table-transfers'
+import { parseRequest } from '@/lib/api/server'
+import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ createTableExportResource,
+ toV2TableExport,
+} from '@/lib/table/orchestration/export-resource'
+import { accessError, checkAccess, orchestrationErrorResponse } from '@/app/api/table/utils'
+
+interface TableRouteParams {
+ params: Promise<{ tableId: string }>
+}
+
+export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
+ const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
+ if (!auth.success || !auth.userId) {
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
+ }
+ const parsed = await parseRequest(createTableExportResourceContract, request, context)
+ if (!parsed.success) return parsed.response
+ const access = await checkAccess(parsed.data.params.tableId, auth.userId, 'read')
+ if (!access.ok) return accessError(access, 'table-export')
+ if (access.table.workspaceId !== parsed.data.body.workspaceId) {
+ return NextResponse.json({ error: 'Table not found' }, { status: 404 })
+ }
+ try {
+ const record = await createTableExportResource({
+ table: access.table,
+ format: parsed.data.body.format,
+ })
+ return NextResponse.json({ data: toV2TableExport(record, true) }, { status: 201 })
+ } catch (error) {
+ const classified = orchestrationErrorResponse(error)
+ if (classified) return classified
+ throw error
+ }
+})
diff --git a/apps/sim/app/api/table/[tableId]/import/route.test.ts b/apps/sim/app/api/table/[tableId]/import/route.test.ts
index baf8c313a4f..a2689295725 100644
--- a/apps/sim/app/api/table/[tableId]/import/route.test.ts
+++ b/apps/sim/app/api/table/[tableId]/import/route.test.ts
@@ -79,6 +79,7 @@ vi.mock('@/lib/table/billing', () => ({
limit >= 0 && current + added > limit,
}))
+import { OrchestrationError } from '@/lib/core/orchestration/types'
import { TableLockedError } from '@/lib/table/mutation-locks'
import { POST } from '@/app/api/table/[tableId]/import/route'
@@ -372,7 +373,10 @@ describe('POST /api/table/[tableId]/import', () => {
it('surfaces unique violations from importAppendRows as 400', async () => {
mockImportAppendRows.mockRejectedValueOnce(
- new Error('Row 1: Column "name" must be unique. Value "Alice" already exists in row row_xxx')
+ new OrchestrationError(
+ 'validation',
+ 'Row 1: Column "name" must be unique. Value "Alice" already exists in row row_xxx'
+ )
)
const response = await callPost(
createFormData(createCsvFile('name,age\nAlice,30'), { mode: 'append' })
@@ -516,7 +520,9 @@ describe('POST /api/table/[tableId]/import', () => {
})
it('surfaces column-creation failures from importAppendRows as 400', async () => {
- mockImportAppendRows.mockRejectedValueOnce(new Error('Column "email" already exists'))
+ mockImportAppendRows.mockRejectedValueOnce(
+ new OrchestrationError('validation', 'Column "email" already exists')
+ )
const response = await callPost(
createFormData(createCsvFile('name,age,email\nAlice,30,a@x.io'), {
mode: 'append',
@@ -529,7 +535,9 @@ describe('POST /api/table/[tableId]/import', () => {
})
it('surfaces row insert failures without success when schema was mutated', async () => {
- mockImportAppendRows.mockRejectedValueOnce(new Error('must be unique'))
+ mockImportAppendRows.mockRejectedValueOnce(
+ new OrchestrationError('validation', 'must be unique')
+ )
const response = await callPost(
createFormData(createCsvFile('name,age,email\nAlice,30,a@x.io'), {
mode: 'append',
diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts
index 8ee9ed8f170..465777f46a3 100644
--- a/apps/sim/app/api/table/[tableId]/import/route.ts
+++ b/apps/sim/app/api/table/[tableId]/import/route.ts
@@ -1,7 +1,5 @@
import type { Readable } from 'node:stream'
import { createLogger } from '@sim/logger'
-import { toError } from '@sim/utils/errors'
-import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import {
csvExtensionSchema,
@@ -14,38 +12,18 @@ import {
import { ianaTimezoneSchema } from '@/lib/api/contracts/user'
import { getValidationErrorMessage } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import { statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import {
- buildAutoMapping,
- CSV_MAX_FILE_SIZE_BYTES,
- type CsvHeaderMapping,
- CsvImportValidationError,
- coerceRowsForTable,
- createCsvParser,
- dispatchAfterBatchInsert,
- generateColumnId,
- getMaxRowsPerTable,
- inferColumnType,
- markTableJobRunning,
- releaseJobClaim,
- sanitizeName,
- type TableDefinition,
- type TableSchema,
- validateMapping,
- wouldExceedRowLimit,
-} from '@/lib/table'
-import { sniffCsvDelimiterFromStream } from '@/lib/table/csv-delimiter-stream'
-import { signalTableSchemaChanged } from '@/lib/table/events'
-import { importAppendRows, importReplaceRows } from '@/lib/table/import-data'
+import { CSV_MAX_FILE_SIZE_BYTES, type CsvHeaderMapping } from '@/lib/table'
+import { performTableCsvImport } from '@/lib/table/orchestration'
import { getUserSettings } from '@/lib/users/queries'
import {
accessError,
checkAccess,
csvProxyBodyCapResponse,
multipartErrorResponse,
- tableLockErrorResponse,
} from '@/app/api/table/utils'
const logger = createLogger('TableImportCSVExisting')
@@ -62,7 +40,6 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
const requestId = generateRequestId()
const { tableId } = tableIdParamsSchema.parse(await params)
let fileStream: Readable | undefined
- let claimedImportId: string | null = null
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
@@ -131,18 +108,6 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
- if (table.archivedAt) {
- return NextResponse.json({ error: 'Cannot import into an archived table' }, { status: 400 })
- }
- // Don't run a sync import on top of an in-flight background job — concurrent writers
- // would insert at colliding row positions.
- if (table.jobStatus === 'running') {
- return NextResponse.json(
- { error: 'A job is already in progress for this table' },
- { status: 409 }
- )
- }
-
let mapping: CsvHeaderMapping | undefined
if (fields.mapping) {
const mappingValidation = csvImportMappingSchema.safeParse(fields.mapping)
@@ -179,264 +144,46 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
timezone = timezoneValidation.data
}
- // The extension only picks the fallback — the separator is sniffed from the file's
- // head so semicolon/pipe exports (European-locale Excel) don't land in one column.
- const { delimiter, stream: csvStream } = await sniffCsvDelimiterFromStream(
- file.stream,
- extensionValidation.data === 'tsv' ? '\t' : ','
- )
- let headers: string[] = []
- const parser = createCsvParser(delimiter, (parsedHeaders) => {
- headers = parsedHeaders
+ const outcome = await performTableCsvImport({
+ table,
+ workspaceId,
+ userId: authResult.userId,
+ fileStream: file.stream,
+ fileName: file.filename,
+ fallbackDelimiter: extensionValidation.data === 'tsv' ? '\t' : ',',
+ mode,
+ mapping,
+ createColumns,
+ timezone,
+ requestId,
})
- // `.pipe` doesn't forward source errors; forward them so the iterator throws.
- csvStream.on('error', (streamErr) => parser.destroy(streamErr))
- csvStream.pipe(parser)
- const rows: Record[] = []
- for await (const record of parser as AsyncIterable>) {
- rows.push(record)
- }
- if (rows.length === 0) {
- return NextResponse.json({ error: 'CSV file has no data rows' }, { status: 400 })
- }
-
- let effectiveMapping = mapping ?? buildAutoMapping(headers, table.schema)
- let prospectiveTable: TableDefinition = table
- const additions: { id?: string; name: string; type: string }[] = []
-
- if (createColumns && createColumns.length > 0) {
- const headerSet = new Set(headers)
- const unknownHeaders = createColumns.filter((h) => !headerSet.has(h))
- if (unknownHeaders.length > 0) {
- return NextResponse.json(
- {
- error: `createColumns references unknown CSV headers: ${unknownHeaders.join(', ')}`,
- },
- { status: 400 }
- )
- }
-
- const usedNames = new Set(table.schema.columns.map((c) => c.name.toLowerCase()))
- const updatedMapping: CsvHeaderMapping = { ...effectiveMapping }
- const newColumns: TableSchema['columns'] = []
- for (const header of createColumns) {
- const base = sanitizeName(header)
- let columnName = base
- let suffix = 2
- while (usedNames.has(columnName.toLowerCase())) {
- columnName = `${base}_${suffix}`
- suffix++
- }
- usedNames.add(columnName.toLowerCase())
- const inferredType = inferColumnType(rows.map((r) => r[header]))
- // Pre-assign the id so the prospective schema (used to coerce rows) and
- // the persisted column (created in importAppendRows) share the same key.
- const id = generateColumnId()
- additions.push({ id, name: columnName, type: inferredType })
- newColumns.push({
- id,
- name: columnName,
- type: inferredType as TableSchema['columns'][number]['type'],
- required: false,
- unique: false,
- })
- updatedMapping[header] = columnName
+ if (!outcome.success) {
+ // A lock rejection renders `{ error, lock }` and deliberately carries NO
+ // `details`: the client's `isValidationError` treats any array-valued
+ // `details` as a field-validation error and swallows the toast.
+ if (outcome.errorCode === 'locked') {
+ return NextResponse.json({ error: outcome.error, lock: outcome.lock }, { status: 423 })
}
-
- prospectiveTable = {
- ...table,
- schema: { columns: [...table.schema.columns, ...newColumns] },
- }
- effectiveMapping = updatedMapping
- }
-
- let validation: ReturnType
- try {
- validation = validateMapping({
- csvHeaders: headers,
- mapping: effectiveMapping,
- tableSchema: prospectiveTable.schema,
- })
- } catch (err) {
- if (err instanceof CsvImportValidationError) {
- return NextResponse.json({ error: err.message, details: err.details }, { status: 400 })
- }
- throw err
- }
-
- if (validation.mappedHeaders.length === 0) {
return NextResponse.json(
{
- error: `No CSV headers map to columns on the table. CSV headers: ${headers.join(', ')}. Table columns: ${prospectiveTable.schema.columns.map((c) => c.name).join(', ')}`,
+ error: outcome.errorCode === 'internal' ? 'Failed to import CSV' : outcome.error,
+ ...(outcome.details !== undefined ? { details: outcome.details } : {}),
+ // The append dialog reads this to distinguish "nothing landed" from a
+ // partial import; only that mode has ever carried it.
+ ...(mode === 'append' ? { data: { insertedCount: 0 } } : {}),
},
- { status: 400 }
- )
- }
-
- const coerced = coerceRowsForTable(rows, prospectiveTable.schema, validation.effectiveMap, {
- timezone,
- })
-
- // Atomically claim the table before writing. The pre-check above reads a checkAccess snapshot
- // taken before the parse/validation; a background import could claim the table in that window.
- // markTableJobRunning is the single atomic gate (same one the async kickoff uses) — released in
- // the finally so a sync import can't write concurrently with a background one (corrupts replace).
- const syncImportId = generateId()
- if (!(await markTableJobRunning(tableId, syncImportId, 'import'))) {
- return NextResponse.json(
- { error: 'A job is already in progress for this table' },
- { status: 409 }
+ { status: statusForOrchestrationError(outcome.errorCode) }
)
}
- claimedImportId = syncImportId
-
- if (mode === 'append') {
- const maxRows = await getMaxRowsPerTable(workspaceId)
- if (wouldExceedRowLimit(maxRows, prospectiveTable.rowCount, coerced.length)) {
- const deficit = prospectiveTable.rowCount + coerced.length - maxRows
- return NextResponse.json(
- {
- error: `Append would exceed table row limit (${maxRows}). Currently ${prospectiveTable.rowCount} rows, ${coerced.length} new rows, ${deficit} over.`,
- },
- { status: 400 }
- )
- }
-
- try {
- const { inserted: insertedRows, table: finalTable } = await importAppendRows(
- table,
- additions,
- coerced,
- { workspaceId, userId: authResult.userId, requestId }
- )
- const inserted = insertedRows.length
- // Fire trigger + scheduler AFTER the tx commits — both read through the
- // global db connection and would otherwise see no rows.
- dispatchAfterBatchInsert(finalTable, insertedRows, requestId, authResult.userId)
-
- logger.info(`[${requestId}] Append CSV imported`, {
- tableId: table.id,
- fileName: file.filename,
- mode,
- inserted,
- createdColumns: additions.length,
- mappedColumns: validation.mappedHeaders.length,
- skippedHeaders: validation.skippedHeaders.length,
- })
- signalTableSchemaChanged(tableId)
- return NextResponse.json({
- success: true,
- data: {
- tableId: table.id,
- mode,
- insertedCount: inserted,
- mappedColumns: validation.mappedHeaders,
- skippedHeaders: validation.skippedHeaders,
- unmappedColumns: validation.unmappedColumns,
- sourceFile: file.filename,
- },
- })
- } catch (err) {
- // This branch returns rather than rethrowing, so the outer catch's
- // mapper is unreachable from here — map the lock error first or a 423
- // degrades into a generic 500 (replace mode rethrows and maps fine).
- const lockError = tableLockErrorResponse(err)
- if (lockError) return lockError
-
- const message = toError(err).message
- logger.warn(`[${requestId}] Append failed for table ${tableId}`, {
- total: coerced.length,
- createdColumns: additions.length,
- error: message,
- })
- const isClientError =
- message.includes('row limit') ||
- message.includes('Insufficient capacity') ||
- message.includes('Schema validation') ||
- message.includes('must be unique') ||
- message.includes('Row size exceeds') ||
- message.includes('already exists') ||
- message.includes('Invalid column name') ||
- /^Row \d+:/.test(message)
- return NextResponse.json(
- {
- error: isClientError ? message : 'Failed to import CSV',
- data: { insertedCount: 0 },
- },
- { status: isClientError ? 400 : 500 }
- )
- }
- }
-
- try {
- const result = await importReplaceRows(
- table,
- additions,
- { rows: coerced, workspaceId, userId: authResult.userId },
- requestId
- )
-
- logger.info(`[${requestId}] Replace CSV imported`, {
- tableId: table.id,
- fileName: file.filename,
- mode,
- deleted: result.deletedCount,
- inserted: result.insertedCount,
- createdColumns: additions.length,
- mappedColumns: validation.mappedHeaders.length,
- })
- signalTableSchemaChanged(tableId)
-
- return NextResponse.json({
- success: true,
- data: {
- tableId: table.id,
- mode,
- deletedCount: result.deletedCount,
- insertedCount: result.insertedCount,
- mappedColumns: validation.mappedHeaders,
- skippedHeaders: validation.skippedHeaders,
- unmappedColumns: validation.unmappedColumns,
- sourceFile: file.filename,
- },
- })
- } catch (err) {
- const message = toError(err).message
- const isClientError =
- message.includes('row limit') ||
- message.includes('Schema validation') ||
- message.includes('must be unique') ||
- message.includes('Row size exceeds') ||
- message.includes('already exists') ||
- message.includes('Invalid column name') ||
- /^Row \d+:/.test(message)
- if (isClientError) {
- return NextResponse.json({ error: message }, { status: 400 })
- }
- throw err
- }
+ return NextResponse.json({ success: true, data: outcome.data })
} catch (error) {
- const lockError = tableLockErrorResponse(error)
- if (lockError) return lockError
if (isMultipartError(error)) return multipartErrorResponse(error)
- const message = toError(error).message
logger.error(`[${requestId}] CSV import into existing table failed:`, error)
-
- const isClientError =
- message.includes('CSV file has no') ||
- message.includes('already exists') ||
- message.includes('Invalid column name')
-
- return NextResponse.json(
- { error: isClientError ? message : 'Failed to import CSV' },
- { status: isClientError ? 400 : 500 }
- )
+ return NextResponse.json({ error: 'Failed to import CSV' }, { status: 500 })
} finally {
fileStream?.destroy()
- // Release before the response returns, so a client refetch never observes the transient claim.
- if (claimedImportId) await releaseJobClaim(tableId, claimedImportId).catch(() => {})
}
})
diff --git a/apps/sim/app/api/table/[tableId]/route.test.ts b/apps/sim/app/api/table/[tableId]/route.test.ts
index 7f1f48243c7..2396ba13a21 100644
--- a/apps/sim/app/api/table/[tableId]/route.test.ts
+++ b/apps/sim/app/api/table/[tableId]/route.test.ts
@@ -33,6 +33,13 @@ vi.mock('@/lib/table', () => ({
updateTableLocks: mockUpdateTableLocks,
TableConflictError: class extends Error {},
}))
+vi.mock('@/lib/table/service', () => ({
+ deleteTable: mockDeleteTable,
+ getTableById: mockGetTableById,
+ moveTableToFolder: mockMoveTableToFolder,
+ renameTable: mockRenameTable,
+ updateTableLocks: mockUpdateTableLocks,
+}))
vi.mock('@/lib/table/billing', () => ({ getWorkspaceTableLimits: mockGetLimits }))
vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mockFindActiveFolder }))
vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: vi.fn() }))
@@ -77,6 +84,13 @@ const routeContext = { params: Promise.resolve({ tableId: 'tbl_1' }) }
describe('PATCH /api/table/[tableId] folder moves', () => {
beforeEach(() => {
vi.clearAllMocks()
+ mockMoveTableToFolder.mockResolvedValue({ name: 'Table' })
+ mockRenameTable.mockResolvedValue({ id: 'tbl_1', name: 'Table' })
+ mockDeleteTable.mockResolvedValue({ archived: { name: 'Table', workspaceId: 'workspace-1' } })
+ mockUpdateTableLocks.mockResolvedValue({
+ table: { ...TABLE, locks: {} },
+ previousLocks: {},
+ })
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
@@ -99,8 +113,7 @@ describe('PATCH /api/table/[tableId] folder moves', () => {
'tbl_1',
'workspace-1',
'folder-1',
- expect.any(String),
- 'user-1'
+ expect.any(String)
)
})
@@ -118,8 +131,7 @@ describe('PATCH /api/table/[tableId] folder moves', () => {
'tbl_1',
'workspace-1',
null,
- expect.any(String),
- 'user-1'
+ expect.any(String)
)
})
diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts
index 225e7556613..12f40106575 100644
--- a/apps/sim/app/api/table/[tableId]/route.ts
+++ b/apps/sim/app/api/table/[tableId]/route.ts
@@ -5,21 +5,19 @@ import { getTableQuerySchema, updateTableContract } from '@/lib/api/contracts/ta
import { isZodError, parseRequest, validationErrorResponse } from '@/lib/api/server/validation'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
+import { statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { findActiveFolder } from '@/lib/folders/queries'
-import { captureServerEvent } from '@/lib/posthog/server'
-import {
- deleteTable,
- getTableById,
- moveTableToFolder,
- renameTable,
- TableConflictError,
- type TableSchema,
- updateTableLocks,
-} from '@/lib/table'
+import { getTableById, TableConflictError, type TableSchema } from '@/lib/table'
import { getWorkspaceTableLimits } from '@/lib/table/billing'
import { signalTableSchemaChanged } from '@/lib/table/events'
+import {
+ performDeleteTable,
+ performMoveTableToFolder,
+ performRenameTable,
+ performUpdateTableLocks,
+} from '@/lib/table/orchestration'
import { TABLE_LOCK_FLAGS, TABLE_LOCK_KINDS } from '@/lib/table/types'
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
import {
@@ -181,13 +179,35 @@ export const PATCH = withRouteHandler(
{ status: 403 }
)
}
- await updateTableLocks(tableId, validated.locks, authResult.userId, requestId, request)
+ const lockOutcome = await performUpdateTableLocks({
+ tableId,
+ partial: validated.locks,
+ userId: authResult.userId,
+ requestId,
+ request,
+ })
+ if (!lockOutcome.success) {
+ return NextResponse.json(
+ { error: lockOutcome.error ?? 'Failed to update table locks' },
+ { status: statusForOrchestrationError(lockOutcome.errorCode) }
+ )
+ }
}
if (validated.name !== undefined) {
- await renameTable(tableId, validated.name, requestId, authResult.userId)
- // Live-collab: tell open viewers the definition changed so they refetch.
- signalTableSchemaChanged(tableId)
+ const renameOutcome = await performRenameTable({
+ table,
+ newName: validated.name,
+ userId: authResult.userId,
+ requestId,
+ request,
+ })
+ if (!renameOutcome.success) {
+ return NextResponse.json(
+ { error: renameOutcome.error ?? 'Failed to rename table' },
+ { status: statusForOrchestrationError(renameOutcome.errorCode) }
+ )
+ }
}
if (validated.folderId !== undefined) {
@@ -199,24 +219,28 @@ export const PATCH = withRouteHandler(
) {
return NextResponse.json({ error: 'Folder not found in this workspace' }, { status: 404 })
}
- try {
- await moveTableToFolder(
- tableId,
- table.workspaceId,
- validated.folderId,
- requestId,
- authResult.userId
+ // The move re-asserts workspace and active state, so a miss means the table was
+ // archived between `checkAccess` and the write. That is a 404, not a server fault.
+ const moveOutcome = await performMoveTableToFolder({
+ table,
+ folderId: validated.folderId,
+ userId: authResult.userId,
+ requestId,
+ request,
+ })
+ if (!moveOutcome.success) {
+ return NextResponse.json(
+ {
+ error: moveOutcome.errorCode === 'not_found' ? 'Table not found' : moveOutcome.error,
+ },
+ { status: statusForOrchestrationError(moveOutcome.errorCode) }
)
- } catch (moveError) {
- // The move re-asserts workspace and active state, so a miss means the table was
- // archived between `checkAccess` and the write. That is a 404, not a server fault.
- if (moveError instanceof Error && moveError.message.endsWith('not found')) {
- return NextResponse.json({ error: 'Table not found' }, { status: 404 })
- }
- throw moveError
}
}
+ // Live-collab: tell open viewers the definition changed so they refetch.
+ signalTableSchemaChanged(tableId)
+
// Re-read so the response reflects both a rename and a lock change.
const updated = await getTableById(tableId)
if (!updated) {
@@ -271,14 +295,18 @@ export const DELETE = withRouteHandler(
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
- await deleteTable(tableId, requestId, authResult.userId)
-
- captureServerEvent(
- authResult.userId,
- 'table_deleted',
- { table_id: tableId, workspace_id: table.workspaceId },
- { groups: { workspace: table.workspaceId } }
- )
+ const outcome = await performDeleteTable({
+ table,
+ userId: authResult.userId,
+ requestId,
+ request,
+ })
+ if (!outcome.success) {
+ return NextResponse.json(
+ { error: outcome.error ?? 'Failed to delete table' },
+ { status: statusForOrchestrationError(outcome.errorCode) }
+ )
+ }
return NextResponse.json({
success: true,
diff --git a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts
index 6cb994bff85..88776a69a20 100644
--- a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts
+++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts
@@ -1,7 +1,6 @@
import { db } from '@sim/db'
import { userTableRows } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { toError } from '@sim/utils/errors'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
@@ -11,16 +10,18 @@ import {
} from '@/lib/api/contracts/tables'
import { isZodError, parseRequest, validationErrorResponse } from '@/lib/api/server/validation'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import { statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { RowData, TableSchema } from '@/lib/table'
-import { deleteRow, updateRow } from '@/lib/table'
+import { updateRow } from '@/lib/table'
import { signalTableRowsChanged } from '@/lib/table/events'
+import { performDeleteTableRow } from '@/lib/table/orchestration'
import { rowWireTranslators } from '@/app/api/table/row-wire'
import {
accessError,
checkAccess,
- rootErrorMessage,
+ orchestrationErrorResponse,
rowWriteErrorResponse,
tableLockErrorResponse,
} from '@/app/api/table/utils'
@@ -145,12 +146,12 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR
table,
requestId
)
+
+ // Live-collab: tell open viewers the change landed so they refetch.
+ signalTableRowsChanged(tableId)
// Only `null` when a `cancellationGuard` is supplied and the SQL guard
// rejects the write — this route doesn't pass one, so reaching null is a bug.
if (!updatedRow) throw new Error('updateRow returned null without a cancellationGuard')
- // An edit that also triggers a dispatch already emits dispatch/cell events; the
- // debounced rows refetch on the peer coalesces the two.
- signalTableRowsChanged(tableId)
// Auto-dispatch for user edits is handled inside `updateRow` (mode: 'new').
// Firing a second mode: 'incomplete' dispatch here would race with the
// `mode: 'new'` one AND bulk-clear sibling-group outputs (the incomplete
@@ -177,10 +178,6 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR
},
})
} catch (error) {
- if (rootErrorMessage(error) === 'Row not found') {
- return NextResponse.json({ error: 'Row not found' }, { status: 404 })
- }
-
const response = rowWriteErrorResponse(error)
if (response) return response
@@ -216,7 +213,15 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
- await deleteRow(table, rowId, requestId)
+ const outcome = await performDeleteTableRow({ table, rowId, requestId })
+ if (!outcome.success) {
+ return NextResponse.json(
+ { error: outcome.error ?? 'Failed to delete row' },
+ { status: statusForOrchestrationError(outcome.errorCode) }
+ )
+ }
+
+ // Live-collab: tell open viewers the change landed so they refetch.
signalTableRowsChanged(tableId)
return NextResponse.json({
@@ -230,11 +235,8 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row
const lockError = tableLockErrorResponse(error)
if (lockError) return lockError
- const errorMessage = toError(error).message
-
- if (errorMessage === 'Row not found') {
- return NextResponse.json({ error: errorMessage }, { status: 404 })
- }
+ const classified = orchestrationErrorResponse(error)
+ if (classified) return classified
logger.error(`[${requestId}] Error deleting row:`, error)
return NextResponse.json({ error: 'Failed to delete row' }, { status: 500 })
diff --git a/apps/sim/app/api/table/exports/[exportId]/download/route.ts b/apps/sim/app/api/table/exports/[exportId]/download/route.ts
new file mode 100644
index 00000000000..93ba5175585
--- /dev/null
+++ b/apps/sim/app/api/table/exports/[exportId]/download/route.ts
@@ -0,0 +1,47 @@
+import { type NextRequest, NextResponse } from 'next/server'
+import { downloadTableExportResourceContract } from '@/lib/api/contracts/table-transfers'
+import { parseRequest } from '@/lib/api/server'
+import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { requireTableExport, tableExportResult } from '@/lib/table/orchestration/export-resource'
+import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service'
+import { accessError, checkAccess, orchestrationErrorResponse } from '@/app/api/table/utils'
+
+const DOWNLOAD_TTL_SECONDS = 60 * 60
+
+interface ExportRouteParams {
+ params: Promise<{ exportId: string }>
+}
+
+export const GET = withRouteHandler(async (request: NextRequest, context: ExportRouteParams) => {
+ const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
+ if (!auth.success || !auth.userId) {
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
+ }
+ const parsed = await parseRequest(downloadTableExportResourceContract, request, context)
+ if (!parsed.success) return parsed.response
+ try {
+ const record = await requireTableExport(
+ parsed.data.params.exportId,
+ parsed.data.query.workspaceId
+ )
+ const access = await checkAccess(record.tableId, auth.userId, 'read')
+ if (!access.ok) return accessError(access, 'table-export')
+ const result = tableExportResult(record)
+ return NextResponse.json({
+ data: {
+ url: await generatePresignedDownloadUrl(
+ result.resultKey,
+ 'workspace',
+ DOWNLOAD_TTL_SECONDS
+ ),
+ fileName: result.resultKey.split('/').pop() ?? `export.${result.format}`,
+ expiresAt: new Date(Date.now() + DOWNLOAD_TTL_SECONDS * 1000).toISOString(),
+ },
+ })
+ } catch (error) {
+ const classified = orchestrationErrorResponse(error)
+ if (classified) return classified
+ throw error
+ }
+})
diff --git a/apps/sim/app/api/table/exports/[exportId]/route.ts b/apps/sim/app/api/table/exports/[exportId]/route.ts
new file mode 100644
index 00000000000..c7e9f56b405
--- /dev/null
+++ b/apps/sim/app/api/table/exports/[exportId]/route.ts
@@ -0,0 +1,68 @@
+import { type NextRequest, NextResponse } from 'next/server'
+import {
+ cancelTableExportResourceContract,
+ getTableExportResourceContract,
+} from '@/lib/api/contracts/table-transfers'
+import { parseRequest } from '@/lib/api/server'
+import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ cancelTableExportResource,
+ requireTableExport,
+ toV2TableExport,
+} from '@/lib/table/orchestration/export-resource'
+import { accessError, checkAccess, orchestrationErrorResponse } from '@/app/api/table/utils'
+
+interface ExportRouteParams {
+ params: Promise<{ exportId: string }>
+}
+
+async function authorizedExport(exportId: string, workspaceId: string, userId: string) {
+ const record = await requireTableExport(exportId, workspaceId)
+ const access = await checkAccess(record.tableId, userId, 'read')
+ return { record, access }
+}
+
+export const GET = withRouteHandler(async (request: NextRequest, context: ExportRouteParams) => {
+ const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
+ if (!auth.success || !auth.userId) {
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
+ }
+ const parsed = await parseRequest(getTableExportResourceContract, request, context)
+ if (!parsed.success) return parsed.response
+ try {
+ const { record, access } = await authorizedExport(
+ parsed.data.params.exportId,
+ parsed.data.query.workspaceId,
+ auth.userId
+ )
+ if (!access.ok) return accessError(access, 'table-export')
+ return NextResponse.json({ data: toV2TableExport(record) })
+ } catch (error) {
+ const classified = orchestrationErrorResponse(error)
+ if (classified) return classified
+ throw error
+ }
+})
+
+export const DELETE = withRouteHandler(async (request: NextRequest, context: ExportRouteParams) => {
+ const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
+ if (!auth.success || !auth.userId) {
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
+ }
+ const parsed = await parseRequest(cancelTableExportResourceContract, request, context)
+ if (!parsed.success) return parsed.response
+ try {
+ const { record, access } = await authorizedExport(
+ parsed.data.params.exportId,
+ parsed.data.query.workspaceId,
+ auth.userId
+ )
+ if (!access.ok) return accessError(access, 'table-export')
+ return NextResponse.json({ data: toV2TableExport(await cancelTableExportResource(record)) })
+ } catch (error) {
+ const classified = orchestrationErrorResponse(error)
+ if (classified) return classified
+ throw error
+ }
+})
diff --git a/apps/sim/app/api/table/import-async/route.ts b/apps/sim/app/api/table/import-async/route.ts
index 57d879c1f32..04039178db7 100644
--- a/apps/sim/app/api/table/import-async/route.ts
+++ b/apps/sim/app/api/table/import-async/route.ts
@@ -18,11 +18,11 @@ import {
releaseJobClaim,
sanitizeName,
TABLE_LIMITS,
- TableConflictError,
} from '@/lib/table'
import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner'
import { getUserSettings } from '@/lib/users/queries'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
+import { orchestrationErrorResponse } from '@/app/api/table/utils'
const logger = createLogger('TableImportAsync')
@@ -101,12 +101,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
requestId
)
} catch (error) {
- if (error instanceof TableConflictError) {
- return NextResponse.json({ error: error.message }, { status: 409 })
- }
- if (error instanceof Error && error.message.includes('maximum table limit')) {
- return NextResponse.json({ error: error.message }, { status: 400 })
- }
+ const classified = orchestrationErrorResponse(error)
+ if (classified) return classified
throw error
}
diff --git a/apps/sim/app/api/table/import-csv/route.test.ts b/apps/sim/app/api/table/import-csv/route.test.ts
index b85e1ccb01b..a9722924755 100644
--- a/apps/sim/app/api/table/import-csv/route.test.ts
+++ b/apps/sim/app/api/table/import-csv/route.test.ts
@@ -2,7 +2,6 @@
* @vitest-environment node
*/
import { hybridAuthMockFns, permissionsMock, permissionsMockFns } from '@sim/testing'
-import { getErrorMessage } from '@sim/utils/errors'
import type { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -32,6 +31,9 @@ vi.mock('@/lib/table/rows/service', () => ({
vi.mock('@/lib/table/billing', () => ({ getWorkspaceTableLimits: mockGetLimits }))
vi.mock('@/app/api/table/utils', async () => {
const { NextResponse } = await import('next/server')
+ const { asOrchestrationError, statusForOrchestrationError } = await import(
+ '@/lib/core/orchestration/types'
+ )
return {
normalizeColumn: (column: unknown) => column,
csvProxyBodyCapResponse: () => null,
@@ -40,16 +42,20 @@ vi.mock('@/app/api/table/utils', async () => {
{ error: error.message },
{ status: error.code === 'FILE_TOO_LARGE' ? 413 : 400 }
),
- rowWriteErrorResponse: (error: unknown) => {
- const message = getErrorMessage(error)
- return message.includes('row limit')
- ? NextResponse.json({ error: message }, { status: 400 })
+ orchestrationErrorResponse: (error: unknown) => {
+ const classified = asOrchestrationError(error)
+ return classified
+ ? NextResponse.json(
+ { error: classified.message },
+ { status: statusForOrchestrationError(classified.code) }
+ )
: null
},
}
})
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
+import { OrchestrationError } from '@/lib/core/orchestration/types'
import { POST } from '@/app/api/table/import-csv/route'
type Part =
@@ -184,7 +190,10 @@ describe('POST /api/table/import-csv', () => {
it('returns 400 with the reason when an insert exceeds the plan row limit', async () => {
mockBatchInsertRows.mockRejectedValueOnce(
- new Error('This table has reached its row limit (1,000 rows) on your current plan.')
+ new OrchestrationError(
+ 'validation',
+ 'This table has reached its row limit (1,000 rows) on your current plan.'
+ )
)
const response = await POST(makeRequest(uploadParts(csvWithRows(250))))
const data = await response.json()
diff --git a/apps/sim/app/api/table/import-csv/route.ts b/apps/sim/app/api/table/import-csv/route.ts
index 9ca0381fe90..ef4f1cc7547 100644
--- a/apps/sim/app/api/table/import-csv/route.ts
+++ b/apps/sim/app/api/table/import-csv/route.ts
@@ -1,41 +1,20 @@
import type { Readable } from 'node:stream'
import { createLogger } from '@sim/logger'
-import { toError } from '@sim/utils/errors'
-import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { csvExtensionSchema, csvImportFormSchema } from '@/lib/api/contracts/tables'
import { ianaTimezoneSchema } from '@/lib/api/contracts/user'
import { getValidationErrorMessage } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import { statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { findActiveFolder } from '@/lib/folders/queries'
-import {
- batchInsertRows,
- CSV_MAX_BATCH_SIZE,
- CSV_MAX_FILE_SIZE_BYTES,
- CSV_SCHEMA_SAMPLE_SIZE,
- coerceRowsForTable,
- createCsvParser,
- createTable,
- deleteTable,
- getWorkspaceTableLimits,
- inferSchemaFromCsv,
- sanitizeName,
- TABLE_LIMITS,
- type TableDefinition,
- type TableSchema,
-} from '@/lib/table'
-import { sniffCsvDelimiterFromStream } from '@/lib/table/csv-delimiter-stream'
+import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table'
+import { performCreateTableFromCsv } from '@/lib/table/orchestration'
import { getUserSettings } from '@/lib/users/queries'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
-import {
- csvProxyBodyCapResponse,
- multipartErrorResponse,
- normalizeColumn,
- rowWriteErrorResponse,
-} from '@/app/api/table/utils'
+import { csvProxyBodyCapResponse, multipartErrorResponse } from '@/app/api/table/utils'
const logger = createLogger('TableImportCSV')
@@ -126,146 +105,31 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
{ status: 400 }
)
}
- // The extension only picks the fallback — the separator is sniffed from the file's
- // head so semicolon/pipe exports (European-locale Excel) don't land in one column.
- const { delimiter, stream: csvStream } = await sniffCsvDelimiterFromStream(
- file.stream,
- extensionResult.data === 'tsv' ? '\t' : ','
- )
- let csvHeaders: string[] = []
- const parser = createCsvParser(delimiter, (headers) => {
- csvHeaders = headers
+ const outcome = await performCreateTableFromCsv({
+ workspaceId,
+ userId,
+ fileStream: file.stream,
+ fileName: file.filename,
+ fallbackDelimiter: extensionResult.data === 'tsv' ? '\t' : ',',
+ folderId,
+ timezone,
+ requestId,
})
- // `.pipe` doesn't forward source errors; forward them so the iterator throws.
- csvStream.on('error', (err) => parser.destroy(err))
- csvStream.pipe(parser)
-
- interface ImportState {
- table: TableDefinition
- schema: TableSchema
- headerToColumn: Map
- }
-
- const insertRows = async (
- rows: Record[],
- state: ImportState,
- currentRowCount: number
- ) => {
- if (rows.length === 0) return 0
- const coerced = coerceRowsForTable(rows, state.schema, state.headerToColumn, { timezone })
- const result = await batchInsertRows(
- { tableId: state.table.id, rows: coerced, workspaceId, userId },
- // The created table's rowCount is frozen at 0; pass the running total so the
- // per-batch capacity check sees cumulative rows, not an always-empty table.
- { ...state.table, rowCount: currentRowCount },
- generateId().slice(0, 8)
- )
- return result.length
- }
- /** Infer the schema from the buffered sample and create the (empty) table. */
- const buildTable = async (sampleRows: Record[]): Promise => {
- const inferred = inferSchemaFromCsv(csvHeaders, sampleRows)
- const schema: TableSchema = { columns: inferred.columns.map(normalizeColumn) }
- const planLimits = await getWorkspaceTableLimits(workspaceId)
- const tableName = sanitizeName(file.filename.replace(/\.[^.]+$/, ''), 'imported_table').slice(
- 0,
- TABLE_LIMITS.MAX_TABLE_NAME_LENGTH
- )
- const table = await createTable(
- {
- name: tableName,
- description: `Imported from ${file.filename}`,
- schema,
- workspaceId,
- folderId,
- userId,
- maxTables: planLimits.maxTables,
- },
- requestId
+ if (!outcome.success) {
+ return NextResponse.json(
+ { error: outcome.errorCode === 'internal' ? 'Failed to import CSV' : outcome.error },
+ { status: statusForOrchestrationError(outcome.errorCode) }
)
- // Coerce against the *created* schema so rows key by the ids `createTable`
- // assigned (the local `schema` is the id-less inferred one).
- return { table, schema: table.schema, headerToColumn: inferred.headerToColumn }
- }
-
- let state: ImportState | null = null
- let inserted = 0
- const sample: Record[] = []
- let batch: Record[] = []
-
- try {
- for await (const record of parser as AsyncIterable>) {
- if (!state) {
- sample.push(record)
- if (sample.length >= CSV_SCHEMA_SAMPLE_SIZE) {
- state = await buildTable(sample)
- inserted += await insertRows(sample, state, inserted)
- }
- continue
- }
- batch.push(record)
- if (batch.length >= CSV_MAX_BATCH_SIZE) {
- inserted += await insertRows(batch, state, inserted)
- batch = []
- }
- }
-
- if (!state) {
- if (sample.length === 0) {
- return NextResponse.json({ error: 'CSV file has no data rows' }, { status: 400 })
- }
- state = await buildTable(sample)
- inserted += await insertRows(sample, state, inserted)
- } else {
- inserted += await insertRows(batch, state, inserted)
- }
- } catch (streamError) {
- if (state) await deleteTable(state.table.id, requestId).catch(() => {})
- throw streamError
}
- logger.info(`[${requestId}] CSV imported`, {
- tableId: state.table.id,
- fileName: file.filename,
- columns: state.schema.columns.length,
- rows: inserted,
- })
-
- return NextResponse.json({
- success: true,
- data: {
- table: {
- id: state.table.id,
- name: state.table.name,
- description: state.table.description,
- schema: state.schema,
- rowCount: inserted,
- },
- },
- })
+ return NextResponse.json({ success: true, data: outcome.data })
} catch (error) {
if (isMultipartError(error)) return multipartErrorResponse(error)
logger.error(`[${requestId}] CSV import failed:`, error)
-
- // Row-write failures (e.g. the plan row-limit check) map to a 400 with the real reason.
- const rowWriteError = rowWriteErrorResponse(error)
- if (rowWriteError) return rowWriteError
-
- const message = toError(error).message
- const isClientError =
- message.includes('maximum table limit') ||
- message.includes('CSV file has no') ||
- message.includes('Invalid table name') ||
- message.includes('Invalid schema') ||
- message.includes('already exists')
-
- return NextResponse.json(
- { error: isClientError ? message : 'Failed to import CSV' },
- { status: isClientError ? 400 : 500 }
- )
+ return NextResponse.json({ error: 'Failed to import CSV' }, { status: 500 })
} finally {
fileStream?.destroy()
}
diff --git a/apps/sim/app/api/table/imports/[importId]/complete/route.ts b/apps/sim/app/api/table/imports/[importId]/complete/route.ts
new file mode 100644
index 00000000000..0de609c4c0d
--- /dev/null
+++ b/apps/sim/app/api/table/imports/[importId]/complete/route.ts
@@ -0,0 +1,51 @@
+import { type NextRequest, NextResponse } from 'next/server'
+import { completeTableImportResourceContract } from '@/lib/api/contracts/table-transfers'
+import { parseRequest } from '@/lib/api/server'
+import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ findOwnedTableImport,
+ getOwnedTableImportUpload,
+ startUploadedTableImport,
+ toV2TableImport,
+} from '@/lib/table/orchestration/import-resource'
+import { completeUploadSession } from '@/lib/uploads/upload-session/service'
+import { orchestrationErrorResponse } from '@/app/api/table/utils'
+
+interface ImportRouteParams {
+ params: Promise<{ importId: string }>
+}
+
+export const POST = withRouteHandler(async (request: NextRequest, context: ImportRouteParams) => {
+ const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
+ if (!auth.success || !auth.userId) {
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
+ }
+ const parsed = await parseRequest(completeTableImportResourceContract, request, context)
+ if (!parsed.success) return parsed.response
+ try {
+ const upload = await getOwnedTableImportUpload({
+ importId: parsed.data.params.importId,
+ workspaceId: parsed.data.query.workspaceId,
+ userId: auth.userId,
+ uploadToken: parsed.data.headers['upload-token'],
+ })
+ const existing = await findOwnedTableImport({
+ importId: upload.id,
+ workspaceId: parsed.data.query.workspaceId,
+ userId: upload.userId,
+ })
+ if (existing) return NextResponse.json({ data: toV2TableImport(existing) })
+ const completed = await completeUploadSession({
+ session: upload,
+ finalize: async () => ({ value: null }),
+ })
+ return NextResponse.json({
+ data: toV2TableImport(await startUploadedTableImport(completed.session)),
+ })
+ } catch (error) {
+ const classified = orchestrationErrorResponse(error)
+ if (classified) return classified
+ throw error
+ }
+})
diff --git a/apps/sim/app/api/table/imports/[importId]/parts/route.ts b/apps/sim/app/api/table/imports/[importId]/parts/route.ts
new file mode 100644
index 00000000000..f86289bf5a2
--- /dev/null
+++ b/apps/sim/app/api/table/imports/[importId]/parts/route.ts
@@ -0,0 +1,39 @@
+import { type NextRequest, NextResponse } from 'next/server'
+import { createTableImportPartUrlsContract } from '@/lib/api/contracts/table-transfers'
+import { parseRequest } from '@/lib/api/server'
+import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getOwnedTableImportUpload } from '@/lib/table/orchestration/import-resource'
+import { createUploadPartUrls } from '@/lib/uploads/upload-session/service'
+import { orchestrationErrorResponse } from '@/app/api/table/utils'
+
+interface ImportRouteParams {
+ params: Promise<{ importId: string }>
+}
+
+export const POST = withRouteHandler(async (request: NextRequest, context: ImportRouteParams) => {
+ const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
+ if (!auth.success || !auth.userId) {
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
+ }
+ const parsed = await parseRequest(createTableImportPartUrlsContract, request, context)
+ if (!parsed.success) return parsed.response
+ try {
+ const upload = await getOwnedTableImportUpload({
+ importId: parsed.data.params.importId,
+ workspaceId: parsed.data.query.workspaceId,
+ userId: auth.userId,
+ uploadToken: parsed.data.headers['upload-token'],
+ })
+ const parts = await createUploadPartUrls({
+ session: upload,
+ partNumbers: parsed.data.body.partNumbers,
+ localOrigin: request.nextUrl.origin,
+ })
+ return NextResponse.json({ data: { parts } })
+ } catch (error) {
+ const classified = orchestrationErrorResponse(error)
+ if (classified) return classified
+ throw error
+ }
+})
diff --git a/apps/sim/app/api/table/imports/[importId]/route.ts b/apps/sim/app/api/table/imports/[importId]/route.ts
new file mode 100644
index 00000000000..15fb5cd2914
--- /dev/null
+++ b/apps/sim/app/api/table/imports/[importId]/route.ts
@@ -0,0 +1,76 @@
+import { type NextRequest, NextResponse } from 'next/server'
+import {
+ cancelTableImportResourceContract,
+ getTableImportResourceContract,
+} from '@/lib/api/contracts/table-transfers'
+import { parseRequest } from '@/lib/api/server'
+import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ abortTableImportUpload,
+ cancelTableImportResource,
+ getOwnedTableImport,
+ toV2TableImport,
+} from '@/lib/table/orchestration/import-resource'
+import { orchestrationErrorResponse } from '@/app/api/table/utils'
+
+interface ImportRouteParams {
+ params: Promise<{ importId: string }>
+}
+
+async function userId(request: NextRequest): Promise {
+ const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
+ return auth.success && auth.userId
+ ? auth.userId
+ : NextResponse.json({ error: 'Authentication required' }, { status: 401 })
+}
+
+export const GET = withRouteHandler(async (request: NextRequest, context: ImportRouteParams) => {
+ const user = await userId(request)
+ if (user instanceof NextResponse) return user
+ const parsed = await parseRequest(getTableImportResourceContract, request, context)
+ if (!parsed.success) return parsed.response
+ try {
+ const record = await getOwnedTableImport({
+ importId: parsed.data.params.importId,
+ workspaceId: parsed.data.query.workspaceId,
+ userId: user,
+ })
+ return NextResponse.json({ data: await toV2TableImport(record) })
+ } catch (error) {
+ const classified = orchestrationErrorResponse(error)
+ if (classified) return classified
+ throw error
+ }
+})
+
+export const DELETE = withRouteHandler(async (request: NextRequest, context: ImportRouteParams) => {
+ const user = await userId(request)
+ if (user instanceof NextResponse) return user
+ const parsed = await parseRequest(cancelTableImportResourceContract, request, context)
+ if (!parsed.success) return parsed.response
+ try {
+ const uploadToken = parsed.data.headers['upload-token']
+ const record = uploadToken
+ ? await abortTableImportUpload({
+ importId: parsed.data.params.importId,
+ workspaceId: parsed.data.query.workspaceId,
+ userId: user,
+ uploadToken,
+ })
+ : await cancelTableImportResource(
+ await getOwnedTableImport({
+ importId: parsed.data.params.importId,
+ workspaceId: parsed.data.query.workspaceId,
+ userId: user,
+ })
+ )
+ return NextResponse.json({
+ data: toV2TableImport(record),
+ })
+ } catch (error) {
+ const classified = orchestrationErrorResponse(error)
+ if (classified) return classified
+ throw error
+ }
+})
diff --git a/apps/sim/app/api/table/imports/route.ts b/apps/sim/app/api/table/imports/route.ts
new file mode 100644
index 00000000000..d7e42288f09
--- /dev/null
+++ b/apps/sim/app/api/table/imports/route.ts
@@ -0,0 +1,31 @@
+import { type NextRequest, NextResponse } from 'next/server'
+import { createTableImportResourceContract } from '@/lib/api/contracts/table-transfers'
+import { parseRequest } from '@/lib/api/server'
+import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ createTableImportResource,
+ toV2CreateTableImport,
+} from '@/lib/table/orchestration/import-resource'
+import { orchestrationErrorResponse } from '@/app/api/table/utils'
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
+ if (!auth.success || !auth.userId) {
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
+ }
+ const parsed = await parseRequest(createTableImportResourceContract, request, {})
+ if (!parsed.success) return parsed.response
+ try {
+ const created = await createTableImportResource(
+ parsed.data.body,
+ auth.userId,
+ request.nextUrl.origin
+ )
+ return NextResponse.json({ data: toV2CreateTableImport(created) }, { status: 201 })
+ } catch (error) {
+ const classified = orchestrationErrorResponse(error)
+ if (classified) return classified
+ throw error
+ }
+})
diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts
index 2522cddb7c6..28714885cb5 100644
--- a/apps/sim/app/api/table/route.ts
+++ b/apps/sim/app/api/table/route.ts
@@ -16,7 +16,7 @@ import {
type TableScope,
} from '@/lib/table'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
-import { normalizeColumn } from '@/app/api/table/utils'
+import { normalizeColumn, orchestrationErrorResponse } from '@/app/api/table/utils'
const logger = createLogger('TableAPI')
@@ -153,18 +153,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
},
})
} catch (error) {
- if (error instanceof Error) {
- if (error.message.includes('maximum table limit')) {
- return NextResponse.json({ error: error.message }, { status: 403 })
- }
- if (
- error.message.includes('Invalid table name') ||
- error.message.includes('Invalid schema') ||
- error.message.includes('already exists')
- ) {
- return NextResponse.json({ error: error.message }, { status: 400 })
- }
- }
+ const classified = orchestrationErrorResponse(error)
+ if (classified) return classified
logger.error(`[${requestId}] Error creating table:`, error)
return NextResponse.json({ error: 'Failed to create table' }, { status: 500 })
diff --git a/apps/sim/app/api/table/utils.test.ts b/apps/sim/app/api/table/utils.test.ts
index 99d0ce0c5a5..fa7b57ac6dd 100644
--- a/apps/sim/app/api/table/utils.test.ts
+++ b/apps/sim/app/api/table/utils.test.ts
@@ -2,6 +2,7 @@
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
+import { OrchestrationError } from '@/lib/core/orchestration/types'
import { TableRowLimitError } from '@/lib/table/billing'
import type { ColumnDefinition } from '@/lib/table/types'
import { rootErrorMessage, rowWriteErrorResponse, tableFilterError } from '@/app/api/table/utils'
@@ -38,15 +39,30 @@ describe('rowWriteErrorResponse', () => {
)
})
- it('passes known validation messages through as 400', async () => {
- const response = rowWriteErrorResponse(new Error('Value for column "email" must be unique'))
+ it('passes a classified validation failure through as 400', async () => {
+ const response = rowWriteErrorResponse(
+ new OrchestrationError('validation', 'Value for column "email" must be unique')
+ )
expect(response?.status).toBe(400)
const body = await response?.json()
expect(body.error).toBe('Value for column "email" must be unique')
})
- it('matches per-row batch validation messages', () => {
- expect(rowWriteErrorResponse(new Error('Row 3: name is required'))?.status).toBe(400)
+ it('answers the code the failure carries, not one derived from its wording', () => {
+ expect(
+ rowWriteErrorResponse(new OrchestrationError('not_found', 'Row not found'))?.status
+ ).toBe(404)
+ // The phrase that used to force a 400 no longer decides anything.
+ expect(
+ rowWriteErrorResponse(new OrchestrationError('conflict', 'Row 3: must be unique'))?.status
+ ).toBe(409)
+ })
+
+ it('unwraps a classified failure drizzle wrapped in a query error', () => {
+ expect(
+ rowWriteErrorResponse(wrapLikeDrizzle(new OrchestrationError('validation', 'Row 3: bad')))
+ ?.status
+ ).toBe(400)
})
it('returns null for unknown errors so callers keep their generic 500', () => {
diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts
index ceb399556c4..805d2b16206 100644
--- a/apps/sim/app/api/table/utils.ts
+++ b/apps/sim/app/api/table/utils.ts
@@ -8,6 +8,7 @@ import {
updateTableColumnBodySchema,
} from '@/lib/api/contracts/tables'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
+import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types'
import type { MultipartError } from '@/lib/core/utils/multipart'
import type { ColumnDefinition, Filter, TableDefinition, TablePredicate } from '@/lib/table'
import { buildFilterClause, getTableById, TableQueryValidationError } from '@/lib/table'
@@ -42,8 +43,9 @@ export async function tablesV2GateError(
* Maps a {@link TableLockedError} thrown by the service layer to a 423 response
* carrying `{ error, lock }`; returns `null` for any other error so the caller
* falls through to its existing handling. Call this as the FIRST statement of a
- * table route's catch block — otherwise `rowWriteErrorResponse` (and the other
- * substring funnels) turn the lock error into a generic 500.
+ * table route's catch block — `TableLockedError` is an `HttpError`, not an
+ * `OrchestrationError`, so nothing else classifies it and it would otherwise
+ * reach the route's generic 500.
*
* The body deliberately omits a `details` array: the client's `isValidationError`
* treats any `ApiClientError` with array-valued `details` as a field-validation
@@ -106,48 +108,36 @@ export function rootErrorMessage(error: unknown): string {
}
/**
- * Known user-facing row-write failures (service validation + the best-effort
- * plan row-limit check). Anything outside this list stays a generic 500 —
- * unknown errors can carry SQL/internals that don't belong in a toast.
- */
-const ROW_WRITE_ERROR_PATTERNS = [
- 'row limit',
- 'Insufficient capacity',
- 'Schema validation',
- 'must be unique',
- 'must be valid',
- 'must be string',
- 'must be number',
- 'must be boolean',
- 'unique column',
- 'Unique constraint violation',
- 'Row size exceeds',
- 'conflictTarget',
- 'Upsert requires',
- 'Rows not found',
- 'Filter is required',
-] as const
-
-/**
- * Maps a known user-facing row-write failure to a 400 carrying the real message
- * (so client toasts can show the actual reason); `null` when the error is
- * unrecognized and the caller should log it and return its generic 500.
+ * Maps a classified domain failure to its status, carrying the real message so
+ * client toasts can show the actual reason; `null` when the error carries no
+ * classification and the caller should log it and return its own generic 500 —
+ * an unrecognized error can hold SQL/internals that don't belong in a toast.
+ *
+ * This is the whole classification story for the UI and v1 table routes. It
+ * replaced per-route lists of message substrings, which decided a status by
+ * searching prose and so silently changed one whenever a message was reworded.
*/
-export function rowWriteErrorResponse(error: unknown): NextResponse | null {
- // A lock violation is a 423, not a 400/500 — check before the pattern match,
- // which would otherwise let it fall through to the caller's generic 500.
+export function orchestrationErrorResponse(error: unknown): NextResponse | null {
+ // A lock violation is a 423, and `TableLockedError` is an `HttpError` rather
+ // than an `OrchestrationError`, so it needs its own check first.
const lockResponse = tableLockErrorResponse(error)
if (lockResponse) return lockResponse
- const message = rootErrorMessage(error)
-
- if (ROW_WRITE_ERROR_PATTERNS.some((p) => message.includes(p)) || /^Row .+?:/.test(message)) {
- return NextResponse.json({ error: message }, { status: 400 })
- }
+ const classified = asOrchestrationError(error)
+ if (!classified) return null
- return null
+ return NextResponse.json(
+ { error: classified.message },
+ { status: statusForOrchestrationError(classified.code) }
+ )
}
+/**
+ * {@link orchestrationErrorResponse} under the name the row-write routes call
+ * it by. Row writes have no classification rules of their own any more.
+ */
+export const rowWriteErrorResponse = orchestrationErrorResponse
+
/**
* Next.js buffers the request body for the proxy and silently truncates it past this
* size (`experimental.proxyClientMaxBodySize`, default 10MB). The synchronous CSV
diff --git a/apps/sim/app/api/tools/deployments/deploy/route.ts b/apps/sim/app/api/tools/deployments/deploy/route.ts
index 5f35c91da86..0795475b549 100644
--- a/apps/sim/app/api/tools/deployments/deploy/route.ts
+++ b/apps/sim/app/api/tools/deployments/deploy/route.ts
@@ -3,10 +3,10 @@ import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/
import { type NextRequest, NextResponse } from 'next/server'
import { deploymentsDeployContract } from '@/lib/api/contracts/tools/deployments'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
+import { statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { performFullDeploy } from '@/lib/workflows/orchestration'
-import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types'
import {
authenticateDeploymentToolRequest,
authorizeDeploymentWorkflow,
diff --git a/apps/sim/app/api/tools/deployments/promote/route.ts b/apps/sim/app/api/tools/deployments/promote/route.ts
index a126c3dbd57..523a5630a32 100644
--- a/apps/sim/app/api/tools/deployments/promote/route.ts
+++ b/apps/sim/app/api/tools/deployments/promote/route.ts
@@ -3,10 +3,10 @@ import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/
import { type NextRequest, NextResponse } from 'next/server'
import { deploymentsPromoteContract } from '@/lib/api/contracts/tools/deployments'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
+import { statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { performActivateVersion } from '@/lib/workflows/orchestration'
-import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types'
import {
authenticateDeploymentToolRequest,
authorizeDeploymentWorkflow,
diff --git a/apps/sim/app/api/users/me/usage-limits/route.ts b/apps/sim/app/api/users/me/usage-limits/route.ts
index 8f2b18d024e..b5ef4610fb8 100644
--- a/apps/sim/app/api/users/me/usage-limits/route.ts
+++ b/apps/sim/app/api/users/me/usage-limits/route.ts
@@ -2,11 +2,10 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { usageLimitsRequestSchema } from '@/lib/api/contracts/usage-limits'
-import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid'
+import { checkHybridAuth } from '@/lib/auth/hybrid'
import { checkServerSideUsageLimits } from '@/lib/billing'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { getUserStorageLimit, getUserStorageUsage } from '@/lib/billing/storage'
-import { RateLimiter } from '@/lib/core/rate-limiter'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { createErrorResponse } from '@/app/api/workflows/utils'
@@ -23,22 +22,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const authenticatedUserId = auth.userId
const userSubscription = await getHighestPrioritySubscription(authenticatedUserId)
- const rateLimiter = new RateLimiter()
- const triggerType = auth.authType === AuthType.API_KEY ? 'api' : 'manual'
- const [syncStatus, asyncStatus] = await Promise.all([
- rateLimiter.getRateLimitStatusWithSubscription(
- authenticatedUserId,
- userSubscription,
- triggerType,
- false
- ),
- rateLimiter.getRateLimitStatusWithSubscription(
- authenticatedUserId,
- userSubscription,
- triggerType,
- true
- ),
- ])
const [usageCheck, storageUsage, storageLimit] = await Promise.all([
checkServerSideUsageLimits(authenticatedUserId),
@@ -52,23 +35,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({
success: true,
- rateLimit: {
- sync: {
- isLimited: syncStatus.remaining === 0,
- requestsPerMinute: syncStatus.requestsPerMinute,
- maxBurst: syncStatus.maxBurst,
- remaining: syncStatus.remaining,
- resetAt: syncStatus.resetAt,
- },
- async: {
- isLimited: asyncStatus.remaining === 0,
- requestsPerMinute: asyncStatus.requestsPerMinute,
- maxBurst: asyncStatus.maxBurst,
- remaining: asyncStatus.remaining,
- resetAt: asyncStatus.resetAt,
- },
- authType: triggerType,
- },
usage: {
currentPeriodCost,
limit: usageCheck.limit,
diff --git a/apps/sim/app/api/users/me/usage-logs/route.test.ts b/apps/sim/app/api/users/me/usage-logs/route.test.ts
index a45c8a0ff92..32295c7f887 100644
--- a/apps/sim/app/api/users/me/usage-logs/route.test.ts
+++ b/apps/sim/app/api/users/me/usage-logs/route.test.ts
@@ -57,7 +57,7 @@ describe('GET /api/users/me/usage-logs', () => {
source: 'workflow',
workflowName: null,
creditCost: 100,
- dollarCost: 0.5,
+ hasCost: true,
},
])
expect(body.summary).toEqual({
diff --git a/apps/sim/app/api/users/me/usage-logs/route.ts b/apps/sim/app/api/users/me/usage-logs/route.ts
index d976d188bc8..9abd381c48c 100644
--- a/apps/sim/app/api/users/me/usage-logs/route.ts
+++ b/apps/sim/app/api/users/me/usage-logs/route.ts
@@ -17,6 +17,7 @@ const logger = createLogger('UsageLogsAPI')
/**
* Lists the authenticated user's credit-consuming usage events (model, tool,
* and fixed charges), converted to credits for display in Billing settings.
+ * Session-only — the API-key-facing equivalent is `GET /api/v2/billing/usage/logs`.
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
@@ -51,7 +52,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
source: log.source,
workflowName: log.workflowName ?? null,
creditCost: creditsByLogId[log.id] ?? 0,
- dollarCost: log.cost,
+ hasCost: log.cost > 0,
}))
const bySourceCredits = Object.fromEntries(
diff --git a/apps/sim/app/api/v1/admin/audit-logs/route.ts b/apps/sim/app/api/v1/admin/audit-logs/route.ts
index 9610232d357..f3dbc231e69 100644
--- a/apps/sim/app/api/v1/admin/audit-logs/route.ts
+++ b/apps/sim/app/api/v1/admin/audit-logs/route.ts
@@ -31,21 +31,13 @@ import {
internalErrorResponse,
listResponse,
} from '@/app/api/v1/admin/responses'
-import {
- type AdminAuditLog,
- createPaginationMeta,
- parsePaginationParams,
- toAdminAuditLog,
-} from '@/app/api/v1/admin/types'
+import { type AdminAuditLog, createPaginationMeta, toAdminAuditLog } from '@/app/api/v1/admin/types'
import { buildFilterConditions } from '@/app/api/v1/audit-logs/query'
const logger = createLogger('AdminAuditLogsAPI')
export const GET = withRouteHandler(
withAdminAuth(async (request) => {
- const url = new URL(request.url)
- const { limit, offset } = parsePaginationParams(url)
-
const parsed = await parseRequest(
v1AdminListAuditLogsContract,
request,
@@ -56,6 +48,7 @@ export const GET = withRouteHandler(
try {
const query = parsed.data.query
+ const { limit, offset } = query
const conditions = buildFilterConditions({
action: query.action,
resourceType: query.resourceType,
diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts
index 69b773accf5..18bc485fbe6 100644
--- a/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts
+++ b/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts
@@ -29,6 +29,7 @@ import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
+ adminInvalidJsonResponse,
adminValidationErrorResponse,
badRequestResponse,
internalErrorResponse,
@@ -152,7 +153,7 @@ export const PATCH = withRouteHandler(
{ params: routeParams },
{
validationErrorResponse: adminValidationErrorResponse,
- invalidJson: 'throw',
+ invalidJsonResponse: adminInvalidJsonResponse,
}
)
if (!parsed.success) return parsed.response
diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts
index 68b79e3a78a..83234df0a7b 100644
--- a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts
+++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts
@@ -45,6 +45,7 @@ import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
+ adminInvalidJsonResponse,
adminValidationErrorResponse,
badRequestResponse,
internalErrorResponse,
@@ -144,7 +145,7 @@ export const PATCH = withRouteHandler(
{ params: routeParams },
{
validationErrorResponse: adminValidationErrorResponse,
- invalidJson: 'throw',
+ invalidJsonResponse: adminInvalidJsonResponse,
}
)
if (!parsed.success) return parsed.response
diff --git a/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts b/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts
index 5c9525ca7ff..e6c84765379 100644
--- a/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts
+++ b/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts
@@ -101,7 +101,10 @@ export const POST = withRouteHandler(
})
} catch (error) {
logger.error('Failed to requeue outbox event', { eventId: id, error: toError(error).message })
- return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
+ return NextResponse.json(
+ { success: false, error: 'Failed to requeue outbox event' },
+ { status: 500 }
+ )
}
})
)
diff --git a/apps/sim/app/api/v1/admin/outbox/route.ts b/apps/sim/app/api/v1/admin/outbox/route.ts
index f88ac55536c..57ce53c49f5 100644
--- a/apps/sim/app/api/v1/admin/outbox/route.ts
+++ b/apps/sim/app/api/v1/admin/outbox/route.ts
@@ -77,7 +77,10 @@ export const GET = withRouteHandler(
})
} catch (error) {
logger.error('Failed to list outbox events', { error: toError(error).message })
- return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
+ return NextResponse.json(
+ { success: false, error: 'Failed to list outbox events' },
+ { status: 500 }
+ )
}
})
)
diff --git a/apps/sim/app/api/v1/admin/referral-campaigns/route.ts b/apps/sim/app/api/v1/admin/referral-campaigns/route.ts
index b7f7c162118..1432b46d37b 100644
--- a/apps/sim/app/api/v1/admin/referral-campaigns/route.ts
+++ b/apps/sim/app/api/v1/admin/referral-campaigns/route.ts
@@ -41,6 +41,7 @@ import { requireStripeClient } from '@/lib/billing/stripe-client'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
+ adminInvalidJsonResponse,
adminValidationErrorResponse,
badRequestResponse,
internalErrorResponse,
@@ -181,7 +182,7 @@ export const POST = withRouteHandler(
{},
{
validationErrorResponse: adminValidationErrorResponse,
- invalidJson: 'throw',
+ invalidJsonResponse: adminInvalidJsonResponse,
}
)
if (!parsed.success) return parsed.response
diff --git a/apps/sim/app/api/v1/audit-logs/auth.ts b/apps/sim/app/api/v1/audit-logs/auth.ts
index 566a17a5e27..60c3d61fc5c 100644
--- a/apps/sim/app/api/v1/audit-logs/auth.ts
+++ b/apps/sim/app/api/v1/audit-logs/auth.ts
@@ -26,10 +26,19 @@ type AuthResult =
| { success: false; response: NextResponse }
/**
- * Validates enterprise audit log access for the given user.
+ * Structured enterprise audit-access result shared by the v1 and v2 surfaces so
+ * each version can render the failure in its own response envelope.
+ */
+export type EnterpriseAuditAccessResult =
+ | { success: true; context: EnterpriseAuditContext }
+ | { success: false; status: number; message: string }
+
+/**
+ * Core enterprise audit-access check (no response rendering).
*
* Checks:
- * 1. User belongs to an organization
+ * 1. User belongs to an organization (the target one when
+ * `targetOrganizationId` is given)
* 2. User has admin or owner role
* 3. The organization is entitled to audit logs — an active enterprise
* subscription when billing runs, otherwise the deployment's audit-logs
@@ -39,13 +48,12 @@ type AuthResult =
* there made audit logs unreachable on every self-hosted deployment, since no
* subscription row is ever written without billing.
*
- * Returns the organization ID and all member user IDs on success,
- * or an error response on failure.
+ * Returns the organization ID and all member user IDs on success.
*/
-export async function validateEnterpriseAuditAccess(
+export async function resolveEnterpriseAuditAccess(
userId: string,
targetOrganizationId?: string
-): Promise {
+): Promise {
const [membership] = await db
.select({ organizationId: member.organizationId, role: member.role })
.from(member)
@@ -57,43 +65,24 @@ export async function validateEnterpriseAuditAccess(
.limit(1)
if (!membership) {
- return {
- success: false,
- response: NextResponse.json({ error: 'Not a member of any organization' }, { status: 403 }),
- }
+ return { success: false, status: 403, message: 'Not a member of any organization' }
}
if (membership.role !== 'admin' && membership.role !== 'owner') {
- return {
- success: false,
- response: NextResponse.json(
- { error: 'Organization admin or owner role required' },
- { status: 403 }
- ),
- }
+ return { success: false, status: 403, message: 'Organization admin or owner role required' }
}
if (isBillingEnabled) {
const billingBlocked = await isOrganizationBillingBlocked(membership.organizationId)
if (billingBlocked) {
- return {
- success: false,
- response: NextResponse.json(
- { error: 'Active enterprise subscription required' },
- { status: 403 }
- ),
- }
+ return { success: false, status: 403, message: 'Active enterprise subscription required' }
}
} else if (!isAuditLogsEnabled) {
return {
success: false,
- response: NextResponse.json(
- {
- error:
- 'Audit logs are disabled. Set ENTERPRISE_ENABLED or AUDIT_LOGS_ENABLED to enable them.',
- },
- { status: 403 }
- ),
+ status: 403,
+ message:
+ 'Audit logs are disabled. Set ENTERPRISE_ENABLED or AUDIT_LOGS_ENABLED to enable them.',
}
}
@@ -118,13 +107,7 @@ export async function validateEnterpriseAuditAccess(
])
if (isBillingEnabled && orgSub.length === 0) {
- return {
- success: false,
- response: NextResponse.json(
- { error: 'Active enterprise subscription required' },
- { status: 403 }
- ),
- }
+ return { success: false, status: 403, message: 'Active enterprise subscription required' }
}
const orgMemberIds = orgMembers.map((m) => m.userId)
@@ -137,9 +120,22 @@ export async function validateEnterpriseAuditAccess(
return {
success: true,
- context: {
- organizationId: membership.organizationId,
- orgMemberIds,
- },
+ context: { organizationId: membership.organizationId, orgMemberIds },
+ }
+}
+
+/**
+ * v1 wrapper: renders {@link resolveEnterpriseAuditAccess} as the v1 `{ error }`
+ * response body.
+ */
+export async function validateEnterpriseAuditAccess(
+ userId: string,
+ targetOrganizationId?: string
+): Promise {
+ const result = await resolveEnterpriseAuditAccess(userId, targetOrganizationId)
+ if (result.success) return { success: true, context: result.context }
+ return {
+ success: false,
+ response: NextResponse.json({ error: result.message }, { status: result.status }),
}
}
diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts
index 94c4832f265..33ac4611ffe 100644
--- a/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts
+++ b/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts
@@ -1,4 +1,3 @@
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { document, knowledgeConnector } from '@sim/db/schema'
import { and, eq, isNull } from 'drizzle-orm'
@@ -8,8 +7,12 @@ import {
v1GetKnowledgeDocumentContract,
} from '@/lib/api/contracts/v1/knowledge'
import { parseRequest } from '@/lib/api/server'
+import {
+ messageForOrchestrationError,
+ statusForOrchestrationError,
+} from '@/lib/core/orchestration/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { deleteDocument } from '@/lib/knowledge/documents/service'
+import { performDeleteKnowledgeDocument } from '@/lib/knowledge/orchestration'
import { handleError, resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils'
import { authenticateRequest, v1ValidationErrorResponse } from '@/app/api/v1/middleware'
@@ -152,19 +155,24 @@ export const DELETE = withRouteHandler(
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
- await deleteDocument(documentId, requestId)
-
- recordAudit({
- workspaceId: parsed.data.query.workspaceId,
- actorId: userId,
- action: AuditAction.DOCUMENT_DELETED,
- resourceType: AuditResourceType.DOCUMENT,
- resourceId: documentId,
- resourceName: docs[0].filename,
- description: `Deleted document "${docs[0].filename}" from knowledge base via API`,
- metadata: { knowledgeBaseId },
+ const outcome = await performDeleteKnowledgeDocument({
+ knowledgeBase: {
+ id: knowledgeBaseId,
+ name: result.kb.name,
+ workspaceId: parsed.data.query.workspaceId,
+ },
+ document: { id: documentId, filename: docs[0].filename },
+ userId,
+ source: 'api',
+ requestId,
request,
})
+ if (!outcome.success) {
+ return NextResponse.json(
+ { error: messageForOrchestrationError(outcome, 'Failed to delete document') },
+ { status: statusForOrchestrationError(outcome.errorCode) }
+ )
+ }
return NextResponse.json({
success: true,
diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts
index 18898d704af..5cba10e6338 100644
--- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts
+++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts
@@ -75,6 +75,9 @@ vi.mock('@/lib/uploads/contexts/workspace', () => ({
vi.mock('@/lib/uploads/utils/validation', () => ({
validateFileType: mockValidateFileType,
+ // Read at module scope by `lib/uploads/utils/file-utils`, which the route now
+ // reaches transitively through the knowledge orchestration module.
+ SUPPORTED_ARCHIVE_EXTENSIONS: [],
}))
vi.mock('@/lib/knowledge/documents/service', () => ({
diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts
index dfd08d4c892..8f77bb467c6 100644
--- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts
+++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts
@@ -1,4 +1,3 @@
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { type NextRequest, NextResponse } from 'next/server'
import {
v1ListKnowledgeDocumentsContract,
@@ -10,19 +9,19 @@ import {
resolveBillingAttribution,
resolveSystemBillingAttribution,
} from '@/lib/billing/core/billing-attribution'
+import {
+ messageForOrchestrationError,
+ statusForOrchestrationError,
+} from '@/lib/core/orchestration/types'
import {
isPayloadSizeLimitError,
MAX_MULTIPART_OVERHEAD_BYTES,
readFormDataWithLimit,
} from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import {
- createSingleDocument,
- type DocumentData,
- getDocuments,
- processDocumentsWithQueue,
-} from '@/lib/knowledge/documents/service'
+import { getDocuments } from '@/lib/knowledge/documents/service'
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
+import { performUploadKnowledgeDocument } from '@/lib/knowledge/orchestration'
import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace'
import { validateFileType } from '@/lib/uploads/utils/validation'
import { handleError, resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils'
@@ -189,47 +188,29 @@ export const POST = withRouteHandler(
contentType
)
- const newDocument = await createSingleDocument(
- {
+ const outcome = await performUploadKnowledgeDocument({
+ knowledgeBase: { id: knowledgeBaseId, name: result.kb.name, workspaceId },
+ document: {
filename: file.name,
fileUrl: uploadedFile.url,
fileSize: file.size,
mimeType: contentType,
},
- knowledgeBaseId,
- requestId,
- billingActorUserId
- )
-
- const documentData: DocumentData = {
- documentId: newDocument.id,
- filename: file.name,
- fileUrl: uploadedFile.url,
- fileSize: file.size,
- mimeType: contentType,
- }
-
- processDocumentsWithQueue(
- [documentData],
- knowledgeBaseId,
- {},
+ startProcessing: 'queue',
+ billingAttribution,
+ uploadedBy: billingActorUserId,
+ userId,
+ source: 'api',
requestId,
- billingAttribution
- ).catch(() => {
- // Processing errors are logged internally
- })
-
- recordAudit({
- workspaceId,
- actorId: userId,
- action: AuditAction.DOCUMENT_UPLOADED,
- resourceType: AuditResourceType.DOCUMENT,
- resourceId: newDocument.id,
- resourceName: file.name,
- description: `Uploaded document "${file.name}" to knowledge base via API`,
- metadata: { knowledgeBaseId, fileSize: file.size, mimeType: contentType },
request,
})
+ if (!outcome.success) {
+ return NextResponse.json(
+ { error: messageForOrchestrationError(outcome, 'Failed to upload document') },
+ { status: statusForOrchestrationError(outcome.errorCode) }
+ )
+ }
+ const newDocument = outcome.document
return NextResponse.json({
success: true,
diff --git a/apps/sim/app/api/v1/knowledge/[id]/route.ts b/apps/sim/app/api/v1/knowledge/[id]/route.ts
index 8dbb280559f..373d0951636 100644
--- a/apps/sim/app/api/v1/knowledge/[id]/route.ts
+++ b/apps/sim/app/api/v1/knowledge/[id]/route.ts
@@ -1,4 +1,3 @@
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { type NextRequest, NextResponse } from 'next/server'
import {
v1DeleteKnowledgeBaseContract,
@@ -6,8 +5,15 @@ import {
v1UpdateKnowledgeBaseContract,
} from '@/lib/api/contracts/v1/knowledge'
import { parseRequest } from '@/lib/api/server'
+import {
+ messageForOrchestrationError,
+ statusForOrchestrationError,
+} from '@/lib/core/orchestration/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { deleteKnowledgeBase, updateKnowledgeBase } from '@/lib/knowledge/service'
+import {
+ performDeleteKnowledgeBase,
+ performUpdateKnowledgeBase,
+} from '@/lib/knowledge/orchestration'
import {
formatKnowledgeBase,
handleError,
@@ -67,33 +73,26 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: Knowle
const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, 'write')
if (result instanceof NextResponse) return result
- const updates: {
- name?: string
- description?: string
- chunkingConfig?: { maxSize: number; minSize: number; overlap: number }
- } = {}
- if (name !== undefined) updates.name = name
- if (description !== undefined) updates.description = description
- if (chunkingConfig !== undefined) updates.chunkingConfig = chunkingConfig
-
- const updatedKb = await updateKnowledgeBase(id, updates, requestId)
-
- recordAudit({
+ const outcome = await performUpdateKnowledgeBase({
+ knowledgeBaseId: id,
workspaceId,
- actorId: userId,
- action: AuditAction.KNOWLEDGE_BASE_UPDATED,
- resourceType: AuditResourceType.KNOWLEDGE_BASE,
- resourceId: id,
- resourceName: updatedKb.name,
- description: `Updated knowledge base "${updatedKb.name}" via API`,
- metadata: { updatedFields: Object.keys(updates) },
+ userId,
+ source: 'api',
+ updates: { name, description, chunkingConfig },
+ requestId,
request,
})
+ if (!outcome.success) {
+ return NextResponse.json(
+ { error: messageForOrchestrationError(outcome, 'Failed to update knowledge base') },
+ { status: statusForOrchestrationError(outcome.errorCode) }
+ )
+ }
return NextResponse.json({
success: true,
data: {
- knowledgeBase: formatKnowledgeBase(updatedKb),
+ knowledgeBase: formatKnowledgeBase(outcome.knowledgeBase),
message: 'Knowledge base updated successfully',
},
})
@@ -125,18 +124,23 @@ export const DELETE = withRouteHandler(
)
if (result instanceof NextResponse) return result
- await deleteKnowledgeBase(id, requestId)
-
- recordAudit({
- workspaceId: parsed.data.query.workspaceId,
- actorId: userId,
- action: AuditAction.KNOWLEDGE_BASE_DELETED,
- resourceType: AuditResourceType.KNOWLEDGE_BASE,
- resourceId: id,
- resourceName: result.kb.name,
- description: `Deleted knowledge base "${result.kb.name}" via API`,
+ const outcome = await performDeleteKnowledgeBase({
+ knowledgeBase: {
+ id,
+ name: result.kb.name,
+ workspaceId: parsed.data.query.workspaceId,
+ },
+ userId,
+ source: 'api',
+ requestId,
request,
})
+ if (!outcome.success) {
+ return NextResponse.json(
+ { error: messageForOrchestrationError(outcome, 'Failed to delete knowledge base') },
+ { status: statusForOrchestrationError(outcome.errorCode) }
+ )
+ }
return NextResponse.json({
success: true,
diff --git a/apps/sim/app/api/v1/knowledge/route.ts b/apps/sim/app/api/v1/knowledge/route.ts
index 5b608484025..cacb36ed482 100644
--- a/apps/sim/app/api/v1/knowledge/route.ts
+++ b/apps/sim/app/api/v1/knowledge/route.ts
@@ -1,13 +1,16 @@
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { type NextRequest, NextResponse } from 'next/server'
import {
v1CreateKnowledgeBaseContract,
v1ListKnowledgeBasesContract,
} from '@/lib/api/contracts/v1/knowledge'
import { parseRequest } from '@/lib/api/server'
+import {
+ messageForOrchestrationError,
+ statusForOrchestrationError,
+} from '@/lib/core/orchestration/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings'
-import { createKnowledgeBase, getKnowledgeBases } from '@/lib/knowledge/service'
+import { performCreateKnowledgeBase } from '@/lib/knowledge/orchestration'
+import { getKnowledgeBases } from '@/lib/knowledge/service'
import { formatKnowledgeBase, handleError } from '@/app/api/v1/knowledge/utils'
import {
authenticateRequest,
@@ -76,35 +79,27 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
if (accessError) return accessError
- const kb = await createKnowledgeBase(
- {
- name,
- description,
- workspaceId,
- userId,
- embeddingModel: getConfiguredEmbeddingModel(),
- embeddingDimension: EMBEDDING_DIMENSIONS,
- chunkingConfig: chunkingConfig ?? { maxSize: 1024, minSize: 100, overlap: 200 },
- },
- requestId
- )
-
- recordAudit({
+ const outcome = await performCreateKnowledgeBase({
+ userId,
+ source: 'api',
workspaceId,
- actorId: userId,
- action: AuditAction.KNOWLEDGE_BASE_CREATED,
- resourceType: AuditResourceType.KNOWLEDGE_BASE,
- resourceId: kb.id,
- resourceName: kb.name,
- description: `Created knowledge base "${kb.name}" via API`,
- metadata: { chunkingConfig },
+ name,
+ description,
+ chunkingConfig,
+ requestId,
request,
})
+ if (!outcome.success) {
+ return NextResponse.json(
+ { error: messageForOrchestrationError(outcome, 'Failed to create knowledge base') },
+ { status: statusForOrchestrationError(outcome.errorCode) }
+ )
+ }
return NextResponse.json({
success: true,
data: {
- knowledgeBase: formatKnowledgeBase(kb),
+ knowledgeBase: formatKnowledgeBase(outcome.knowledgeBase),
message: 'Knowledge base created successfully',
},
})
diff --git a/apps/sim/app/api/v1/logs/filters.ts b/apps/sim/app/api/v1/logs/filters.ts
index 70b89ae5824..ab540813893 100644
--- a/apps/sim/app/api/v1/logs/filters.ts
+++ b/apps/sim/app/api/v1/logs/filters.ts
@@ -1,5 +1,5 @@
import { workflow, workflowExecutionLogs } from '@sim/db/schema'
-import { and, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm'
+import { and, asc, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm'
export interface LogFilters {
workspaceId: string
@@ -103,8 +103,14 @@ export function buildLogFilters(filters: LogFilters): SQL {
return conditions.length > 0 ? and(...conditions)! : sql`true`
}
+/**
+ * Order rows by `(startedAt, id)` so the sort matches the keyset cursor's tuple
+ * comparison in {@link buildLogFilters}. Without the `id` tie-break, rows that
+ * share a `startedAt` have an arbitrary order and can be skipped or duplicated
+ * across pages.
+ */
export function getOrderBy(order: 'desc' | 'asc' = 'desc') {
return order === 'desc'
- ? desc(workflowExecutionLogs.startedAt)
- : sql`${workflowExecutionLogs.startedAt} ASC`
+ ? [desc(workflowExecutionLogs.startedAt), desc(workflowExecutionLogs.id)]
+ : [asc(workflowExecutionLogs.startedAt), asc(workflowExecutionLogs.id)]
}
diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts
index 1da2529a84f..e6da3cb0e2a 100644
--- a/apps/sim/app/api/v1/logs/route.ts
+++ b/apps/sim/app/api/v1/logs/route.ts
@@ -118,7 +118,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const logs = await baseQuery
.where(conditions)
- .orderBy(orderBy)
+ .orderBy(...orderBy)
.limit(params.limit + 1)
const hasMore = logs.length > params.limit
diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts
index bdc82a6613a..4182a4ea4a1 100644
--- a/apps/sim/app/api/v1/middleware.ts
+++ b/apps/sim/app/api/v1/middleware.ts
@@ -30,6 +30,8 @@ export type ApiEndpoint =
| 'workflow-detail'
| 'workflow-deploy'
| 'workflow-rollback'
+ | 'workflow-versions'
+ | 'workflow-version-detail'
| 'workflow-export'
| 'workflow-import'
| 'audit-logs'
@@ -37,15 +39,34 @@ export type ApiEndpoint =
| 'table-detail'
| 'table-rows'
| 'table-row-detail'
+ | 'table-rows-find'
| 'table-columns'
+ | 'table-views'
+ | 'table-view-detail'
+ | 'table-groups'
+ | 'table-enrichment'
+ | 'table-import'
+ | 'table-export'
+ | 'table-jobs'
| 'files'
| 'file-detail'
+ | 'file-share'
+ | 'file-content'
+ | 'file-move'
+ | 'file-bulk-delete'
| 'knowledge'
| 'knowledge-detail'
| 'knowledge-search'
| 'copilot-chat'
- | 'v2-tables'
- | 'v2-table-rows'
+ | 'billing-usage'
+ | 'mcp-servers'
+ | 'mcp-server-detail'
+ | 'skills'
+ | 'skill-detail'
+ | 'custom-tools'
+ | 'custom-tool-detail'
+ | 'credentials'
+ | 'credential-detail'
export interface RateLimitResult {
allowed: boolean
@@ -192,42 +213,83 @@ export function createRateLimitResponse(result: RateLimitResult): NextResponse {
}
/**
- * Verify that the API key is allowed to access the requested workspace.
- *
- * Enforces two policies:
+ * Structured workspace-access failure shared by the v1 and v2 API surfaces so
+ * each version can render the failure in its own response envelope.
+ */
+export interface WorkspaceAccessError {
+ status: number
+ code: 'FORBIDDEN'
+ message: string
+}
+
+/**
+ * Core workspace-scope check (no response rendering). Enforces two policies:
* - A workspace-scoped key may only target its own workspace.
* - A personal key is rejected when the workspace has disabled personal API
* keys (`allowPersonalApiKeys = false`), matching the workflow-execution
* surface in `app/api/workflows/middleware.ts`.
*/
-export async function checkWorkspaceScope(
+export async function resolveWorkspaceScope(
rateLimit: RateLimitResult,
requestedWorkspaceId: string
-): Promise {
+): Promise {
if (
rateLimit.keyType === 'workspace' &&
rateLimit.workspaceId &&
rateLimit.workspaceId !== requestedWorkspaceId
) {
- return NextResponse.json(
- { error: 'API key is not authorized for this workspace' },
- { status: 403 }
- )
+ return {
+ status: 403,
+ code: 'FORBIDDEN',
+ message: 'API key is not authorized for this workspace',
+ }
}
if (rateLimit.keyType === 'personal') {
const settings = await getWorkspaceBillingSettings(requestedWorkspaceId)
if (!settings?.allowPersonalApiKeys) {
- return NextResponse.json(
- { error: 'Personal API keys are not allowed for this workspace' },
- { status: 403 }
- )
+ return {
+ status: 403,
+ code: 'FORBIDDEN',
+ message: 'Personal API keys are not allowed for this workspace',
+ }
}
}
return null
}
+/**
+ * Core workspace-access check (scope + the user's workspace permission level),
+ * shared by v1 and v2. Returns a structured failure or null on success.
+ */
+export async function resolveWorkspaceAccess(
+ rateLimit: RateLimitResult,
+ userId: string,
+ workspaceId: string,
+ level: PermissionType = 'read'
+): Promise {
+ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId)
+ if (scopeError) return scopeError
+
+ const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
+ if (!permissionSatisfies(permission, level)) {
+ return { status: 403, code: 'FORBIDDEN', message: 'Access denied' }
+ }
+ return null
+}
+
+/**
+ * v1 wrapper: renders {@link resolveWorkspaceScope} as the v1 `{ error }` body.
+ */
+export async function checkWorkspaceScope(
+ rateLimit: RateLimitResult,
+ requestedWorkspaceId: string
+): Promise {
+ const failure = await resolveWorkspaceScope(rateLimit, requestedWorkspaceId)
+ return failure ? NextResponse.json({ error: failure.message }, { status: failure.status }) : null
+}
+
/**
* Resolves the usage actor for a workspace-scoped v1 request. Personal keys
* identify their human owner; shared workspace keys use the billed account as
@@ -244,7 +306,7 @@ export async function resolveWorkspaceRequestActor(
}
/**
- * Validates workspace-scoped API key bounds and the user's workspace permission.
+ * v1 wrapper: renders {@link resolveWorkspaceAccess} as the v1 `{ error }` body.
* Returns null on success, NextResponse on failure.
*/
export async function validateWorkspaceAccess(
@@ -253,14 +315,8 @@ export async function validateWorkspaceAccess(
workspaceId: string,
level: PermissionType = 'read'
): Promise {
- const scopeError = await checkWorkspaceScope(rateLimit, workspaceId)
- if (scopeError) return scopeError
-
- const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
- if (!permissionSatisfies(permission, level)) {
- return NextResponse.json({ error: 'Access denied' }, { status: 403 })
- }
- return null
+ const failure = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, level)
+ return failure ? NextResponse.json({ error: failure.message }, { status: failure.status }) : null
}
/**
diff --git a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts
index b5471842a6c..988c24825c9 100644
--- a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts
+++ b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts
@@ -7,25 +7,17 @@ import {
v1UpdateTableColumnContract,
} from '@/lib/api/contracts/v1/tables'
import { parseRequest } from '@/lib/api/server'
+import { statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import {
- addTableColumn,
- deleteColumn,
- renameColumn,
- updateColumnConstraints,
- updateColumnCurrency,
- updateColumnOptions,
- updateColumnType,
-} from '@/lib/table'
-import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys'
-import { columnTypeById } from '@/lib/table/column-types'
-import { isSupportedCurrencyCode } from '@/lib/table/currency'
+import { addTableColumn, deleteColumn } from '@/lib/table'
import { signalTableSchemaChanged } from '@/lib/table/events'
+import { performUpdateTableColumn } from '@/lib/table/orchestration'
import {
accessError,
checkAccess,
normalizeColumn,
+ orchestrationErrorResponse,
tableLockErrorResponse,
} from '@/app/api/table/utils'
import {
@@ -103,22 +95,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum
const validationResponse = v1ValidationErrorResponseFromError(error)
if (validationResponse) return validationResponse
- if (error instanceof Error) {
- // Same caller-error set the internal columns route maps — an invalid
- // select option set is a bad request, not a server fault.
- if (
- error.message.includes('already exists') ||
- error.message.includes('maximum column') ||
- error.message.includes('Invalid column') ||
- error.message.includes('exceeds maximum') ||
- error.message.includes('option')
- ) {
- return NextResponse.json({ error: error.message }, { status: 400 })
- }
- if (error.message === 'Table not found') {
- return NextResponse.json({ error: error.message }, { status: 404 })
- }
- }
+ const classified = orchestrationErrorResponse(error)
+ if (classified) return classified
logger.error(`[${requestId}] Error adding column to table:`, error)
return NextResponse.json({ error: 'Failed to add column' }, { status: 500 })
@@ -156,228 +134,34 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
- const { updates } = validated
- let updatedTable = null
-
- // A payload that repeats the current type must not go through
- // `updateColumnType` — it early-returns on an unchanged type and would drop
- // any `options` alongside it. Only a real type change routes there; an
- // unchanged type with options routes to the options-only update.
- const currentColumn = table.schema.columns.find((c) =>
- columnMatchesRef(c, validated.columnName)
- )
- // Address every write below by the stable id, not the name: a rename folded
- // into one of them must not break the next one's lookup.
- const columnRef = currentColumn ? getColumnId(currentColumn) : validated.columnName
- // The constraints write below is a separate, unconditional step, so it is
- // the last one whenever it runs — that is the write the rename rides on.
- const typeChanging = updates.type !== undefined && updates.type !== currentColumn?.type
- if (!currentColumn) {
- return NextResponse.json(
- { error: `Column "${validated.columnName}" not found` },
- { status: 404 }
- )
- }
-
- // A retype applies and validates the constraints itself, so the separate
- // constraint write only runs when the type is unchanged. The rename rides
- // whichever write actually runs last.
- const typedWriteRuns =
- typeChanging ||
- updates.currencyCode !== undefined ||
- updates.options !== undefined ||
- updates.multiple !== undefined
- const constraintsWriteRuns =
- !typedWriteRuns && (updates.required !== undefined || updates.unique !== undefined)
- const renameWithTypedWrite =
- updates.name && !constraintsWriteRuns ? { newName: updates.name } : {}
-
- // Every write below is its own locked transaction, so one that is going to
- // fail leaves the earlier ones committed. These guards reject the knowable
- // cases up front, before any write at all.
- // Gate on the type the column ENDS UP with, not on whether the type is
- // changing: an options-only update on an existing select column carries the
- // same hazard as a conversion does.
- const resultingType = updates.type ?? currentColumn?.type
- if (updates.currencyCode !== undefined) {
- if (resultingType !== 'currency') {
- return NextResponse.json(
- {
- error: `Cannot set currency on column "${validated.columnName}" of type "${resultingType}"`,
- },
- { status: 400 }
- )
- }
- if (!isSupportedCurrencyCode(updates.currencyCode)) {
- return NextResponse.json(
- {
- error: `Invalid currency code "${updates.currencyCode}". Use an ISO 4217 code, e.g. USD`,
- },
- { status: 400 }
- )
- }
- }
- // The rename runs last (see below), so a name already taken would fail after
- // the typed write committed. This is the only rename failure a caller can
- // cause; catching it here leaves just the concurrent-collision race, which
- // no pre-flight check can close.
- if (
- updates.name &&
- table.schema.columns.some(
- (c) =>
- c.name.toLowerCase() === updates.name?.toLowerCase() &&
- !columnMatchesRef(c, validated.columnName)
- )
- ) {
- return NextResponse.json(
- { error: `Column "${updates.name}" already exists` },
- { status: 400 }
- )
- }
- if (
- currentColumn?.workflowGroupId &&
- (updates.required !== undefined || updates.unique !== undefined)
- ) {
- return NextResponse.json(
- {
- error: `Cannot change constraints on workflow-output column "${currentColumn.name}". Constraints aren't applicable to columns whose values come from workflow execution.`,
- },
- { status: 400 }
- )
- }
- if (updates.unique === true && !columnTypeById(resultingType).supportsUnique) {
+ const outcome = await performUpdateTableColumn({
+ table,
+ columnName: validated.columnName,
+ userId,
+ updates: validated.updates,
+ requestId,
+ request,
+ })
+ if (!outcome.success || !outcome.table) {
return NextResponse.json(
- { error: `Cannot set a ${resultingType} column as unique` },
- { status: 400 }
- )
- }
-
- if (typeChanging) {
- updatedTable = await updateColumnType(
- {
- tableId,
- columnName: columnRef,
- newType: updates.type as NonNullable,
- ...(updates.options !== undefined ? { options: updates.options } : {}),
- ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}),
- ...(updates.currencyCode !== undefined ? { currencyCode: updates.currencyCode } : {}),
- // Forwarded so the conversion validates against the constraint this
- // same request is about to set, not the column's current one.
- ...(updates.required !== undefined ? { required: updates.required } : {}),
- ...(updates.unique !== undefined ? { unique: updates.unique } : {}),
- ...renameWithTypedWrite,
- },
- requestId
- )
- } else if (updates.currencyCode !== undefined) {
- // Re-denominating an existing currency column: schema-only, no cell
- // rewrite. Reached only when the type is unchanged — a conversion INTO
- // currency carries the code through `updateColumnType` above.
- updatedTable = await updateColumnCurrency(
- {
- tableId,
- columnName: columnRef,
- currencyCode: updates.currencyCode,
- ...(updates.required !== undefined ? { required: updates.required } : {}),
- ...(updates.unique !== undefined ? { unique: updates.unique } : {}),
- ...renameWithTypedWrite,
- },
- requestId
- )
- } else if (updates.options !== undefined || updates.multiple !== undefined) {
- updatedTable = await updateColumnOptions(
- {
- tableId,
- columnName: columnRef,
- options: updates.options ?? currentColumn?.options ?? [],
- ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}),
- // Forwarded so the removal guard validates against the constraint this
- // same request is about to set, not the column's current one.
- ...(updates.required !== undefined ? { required: updates.required } : {}),
- ...(updates.unique !== undefined ? { unique: updates.unique } : {}),
- ...renameWithTypedWrite,
- },
- requestId
- )
- }
-
- // Skipped whenever a typed write ran: that write already applied and
- // validated these, in one transaction with the change they accompany.
- if (constraintsWriteRuns) {
- updatedTable = await updateColumnConstraints(
- {
- tableId,
- columnName: columnRef,
- ...(updates.required !== undefined ? { required: updates.required } : {}),
- ...(updates.unique !== undefined ? { unique: updates.unique } : {}),
- ...(updates.name ? { newName: updates.name } : {}),
- },
- requestId
+ { error: outcome.error ?? 'Failed to update column' },
+ { status: statusForOrchestrationError(outcome.errorCode) }
)
}
- // A rename rides along with the LAST write above, inside that write's
- // transaction — a rename is metadata-only (rows key on the stable column
- // id), so nothing forces it to be its own write, and folding it in is what
- // stops a combined request from committing one half and then failing. Only
- // a rename with nothing to ride on runs standalone.
- if (updates.name && !updatedTable) {
- updatedTable = await renameColumn(
- { tableId, oldName: columnRef, newName: updates.name },
- requestId
- )
- }
-
- if (!updatedTable) {
- return NextResponse.json({ error: 'No updates specified' }, { status: 400 })
- }
+ // Live-collab: tell open viewers the change landed so they refetch.
signalTableSchemaChanged(tableId)
- recordAudit({
- workspaceId: validated.workspaceId,
- actorId: userId,
- action: AuditAction.TABLE_UPDATED,
- resourceType: AuditResourceType.TABLE,
- resourceId: tableId,
- resourceName: table.name,
- description: `Updated column "${validated.columnName}" in table "${table.name}"`,
- metadata: { columnName: validated.columnName, updates },
- request,
- })
-
return NextResponse.json({
success: true,
data: {
- columns: updatedTable.schema.columns.map(normalizeColumn),
+ columns: outcome.table.schema.columns.map(normalizeColumn),
},
})
} catch (error) {
- const lockError = tableLockErrorResponse(error)
- if (lockError) return lockError
const validationResponse = v1ValidationErrorResponseFromError(error)
if (validationResponse) return validationResponse
- if (error instanceof Error) {
- const msg = error.message
- if (msg.includes('not found') || msg.includes('Table not found')) {
- return NextResponse.json({ error: msg }, { status: 404 })
- }
- if (
- msg.includes('already exists') ||
- msg.includes('Cannot delete the last column') ||
- msg.includes('Cannot set column') ||
- msg.includes('Invalid column') ||
- msg.includes('exceeds maximum') ||
- msg.includes('incompatible') ||
- msg.includes('duplicate') ||
- msg.includes('option') ||
- msg.includes('currency') ||
- msg.includes('is already type')
- ) {
- return NextResponse.json({ error: msg }, { status: 400 })
- }
- }
-
logger.error(`[${requestId}] Error updating column in table:`, error)
return NextResponse.json({ error: 'Failed to update column' }, { status: 500 })
}
@@ -445,14 +229,8 @@ export const DELETE = withRouteHandler(
const validationResponse = v1ValidationErrorResponseFromError(error)
if (validationResponse) return validationResponse
- if (error instanceof Error) {
- if (error.message.includes('not found') || error.message === 'Table not found') {
- return NextResponse.json({ error: error.message }, { status: 404 })
- }
- if (error.message.includes('Cannot delete') || error.message.includes('last column')) {
- return NextResponse.json({ error: error.message }, { status: 400 })
- }
- }
+ const classified = orchestrationErrorResponse(error)
+ if (classified) return classified
logger.error(`[${requestId}] Error deleting column from table:`, error)
return NextResponse.json({ error: 'Failed to delete column' }, { status: 500 })
diff --git a/apps/sim/app/api/v1/tables/[tableId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/route.ts
index c06492d02b7..149bc674651 100644
--- a/apps/sim/app/api/v1/tables/[tableId]/route.ts
+++ b/apps/sim/app/api/v1/tables/[tableId]/route.ts
@@ -1,11 +1,12 @@
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { v1DeleteTableContract, v1GetTableContract } from '@/lib/api/contracts/v1/tables'
import { parseRequest } from '@/lib/api/server'
+import { statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { deleteTable, type TableSchema } from '@/lib/table'
+import type { TableSchema } from '@/lib/table'
+import { performDeleteTable } from '@/lib/table/orchestration'
import {
accessError,
checkAccess,
@@ -139,18 +140,13 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
- await deleteTable(tableId, requestId)
-
- recordAudit({
- workspaceId,
- actorId: userId,
- action: AuditAction.TABLE_DELETED,
- resourceType: AuditResourceType.TABLE,
- resourceId: tableId,
- resourceName: result.table.name,
- description: `Archived table "${result.table.name}"`,
- request,
- })
+ const outcome = await performDeleteTable({ table: result.table, userId, requestId, request })
+ if (!outcome.success) {
+ return NextResponse.json(
+ { error: outcome.error ?? 'Failed to delete table' },
+ { status: statusForOrchestrationError(outcome.errorCode) }
+ )
+ }
return NextResponse.json({
success: true,
diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts
index fe2a5a022ab..ed7a68753f2 100644
--- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts
+++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts
@@ -1,7 +1,6 @@
import { db } from '@sim/db'
import { userTableRows } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { toError } from '@sim/utils/errors'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
@@ -10,14 +9,21 @@ import {
v1UpdateTableRowContract,
} from '@/lib/api/contracts/v1/tables'
import { parseRequest } from '@/lib/api/server'
+import { statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { RowData, TableSchema } from '@/lib/table'
-import { deleteRow, updateRow } from '@/lib/table'
+import { updateRow } from '@/lib/table'
import { namedRowMapper } from '@/lib/table/cell-format'
import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys'
import { signalTableRowsChanged } from '@/lib/table/events'
-import { accessError, checkAccess, tableLockErrorResponse } from '@/app/api/table/utils'
+import { performDeleteTableRow } from '@/lib/table/orchestration'
+import {
+ accessError,
+ checkAccess,
+ orchestrationErrorResponse,
+ tableLockErrorResponse,
+} from '@/app/api/table/utils'
import {
checkRateLimit,
checkWorkspaceScope,
@@ -155,12 +161,14 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR
table,
requestId
)
+
+ // Live-collab: tell open viewers the change landed so they refetch.
+ signalTableRowsChanged(tableId)
// No `cancellationGuard` is passed here, so `updateRow` can't return null
// from this caller. Defensive narrowing for TypeScript.
if (!updatedRow) {
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
}
- signalTableRowsChanged(tableId)
// Auto-dispatch for user edits is handled inside `updateRow` (mode: 'new').
// Firing a second mode: 'incomplete' dispatch here would race with it AND
// bulk-clear sibling-group outputs.
@@ -190,21 +198,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR
const validationResponse = v1ValidationErrorResponseFromError(error)
if (validationResponse) return validationResponse
- const errorMessage = toError(error).message
-
- if (errorMessage === 'Row not found') {
- return NextResponse.json({ error: errorMessage }, { status: 404 })
- }
-
- if (
- errorMessage.includes('Row size exceeds') ||
- errorMessage.includes('Schema validation') ||
- errorMessage.includes('must be unique') ||
- errorMessage.includes('Unique constraint violation') ||
- errorMessage.includes('Cannot set unique column')
- ) {
- return NextResponse.json({ error: errorMessage }, { status: 400 })
- }
+ const classified = orchestrationErrorResponse(error)
+ if (classified) return classified
logger.error(`[${requestId}] Error updating row:`, error)
return NextResponse.json({ error: 'Failed to update row' }, { status: 500 })
@@ -240,9 +235,15 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
- // Route through the service (not a raw `db.delete`) so the delete lock is
- // enforced — the raw path would return 200 on a locked table.
- await deleteRow(result.table, rowId, requestId)
+ const outcome = await performDeleteTableRow({ table: result.table, rowId, requestId })
+ if (!outcome.success) {
+ return NextResponse.json(
+ { error: outcome.error ?? 'Failed to delete row' },
+ { status: statusForOrchestrationError(outcome.errorCode) }
+ )
+ }
+
+ // Live-collab: tell open viewers the change landed so they refetch.
signalTableRowsChanged(tableId)
return NextResponse.json({
@@ -255,9 +256,8 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row
} catch (error) {
const lockError = tableLockErrorResponse(error)
if (lockError) return lockError
- if (error instanceof Error && error.message === 'Row not found') {
- return NextResponse.json({ error: 'Row not found' }, { status: 404 })
- }
+ const classified = orchestrationErrorResponse(error)
+ if (classified) return classified
logger.error(`[${requestId}] Error deleting row:`, error)
return NextResponse.json({ error: 'Failed to delete row' }, { status: 500 })
}
diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts
index f89bad3096f..6107e99e4e9 100644
--- a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts
+++ b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts
@@ -1,5 +1,4 @@
import { createLogger } from '@sim/logger'
-import { toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { v1UpsertTableRowContract } from '@/lib/api/contracts/v1/tables'
import { parseRequest } from '@/lib/api/server'
@@ -10,7 +9,12 @@ import { upsertRow } from '@/lib/table'
import { namedRowMapper } from '@/lib/table/cell-format'
import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys'
import { signalTableRowsChanged } from '@/lib/table/events'
-import { accessError, checkAccess, tableLockErrorResponse } from '@/app/api/table/utils'
+import {
+ accessError,
+ checkAccess,
+ orchestrationErrorResponse,
+ tableLockErrorResponse,
+} from '@/app/api/table/utils'
import {
checkRateLimit,
checkWorkspaceScope,
@@ -76,6 +80,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser
table,
requestId
)
+
+ // Live-collab: tell open viewers the change landed so they refetch.
signalTableRowsChanged(tableId)
return NextResponse.json({
@@ -103,19 +109,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser
const validationResponse = v1ValidationErrorResponseFromError(error)
if (validationResponse) return validationResponse
- const errorMessage = toError(error).message
-
- if (
- errorMessage.includes('unique column') ||
- errorMessage.includes('Unique constraint violation') ||
- errorMessage.includes('conflictTarget') ||
- errorMessage.includes('row limit') ||
- errorMessage.includes('Schema validation') ||
- errorMessage.includes('Upsert requires') ||
- errorMessage.includes('Row size exceeds')
- ) {
- return NextResponse.json({ error: errorMessage }, { status: 400 })
- }
+ const classified = orchestrationErrorResponse(error)
+ if (classified) return classified
logger.error(`[${requestId}] Error upserting row:`, error)
return NextResponse.json({ error: 'Failed to upsert row' }, { status: 500 })
diff --git a/apps/sim/app/api/v1/tables/route.ts b/apps/sim/app/api/v1/tables/route.ts
index 82bc6618247..6213fd59053 100644
--- a/apps/sim/app/api/v1/tables/route.ts
+++ b/apps/sim/app/api/v1/tables/route.ts
@@ -6,7 +6,7 @@ import { parseRequest } from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { createTable, getWorkspaceTableLimits, listTables, type TableSchema } from '@/lib/table'
-import { normalizeColumn } from '@/app/api/table/utils'
+import { normalizeColumn, orchestrationErrorResponse } from '@/app/api/table/utils'
import {
checkRateLimit,
createRateLimitResponse,
@@ -171,18 +171,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const validationResponse = v1ValidationErrorResponseFromError(error)
if (validationResponse) return validationResponse
- if (error instanceof Error) {
- if (error.message.includes('maximum table limit')) {
- return NextResponse.json({ error: error.message }, { status: 403 })
- }
- if (
- error.message.includes('Invalid table name') ||
- error.message.includes('Invalid schema') ||
- error.message.includes('already exists')
- ) {
- return NextResponse.json({ error: error.message }, { status: 400 })
- }
- }
+ const classified = orchestrationErrorResponse(error)
+ if (classified) return classified
logger.error(`[${requestId}] Error creating table:`, error)
return NextResponse.json({ error: 'Failed to create table' }, { status: 500 })
diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts
index 7068239e134..304d69f63d8 100644
--- a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts
+++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts
@@ -8,11 +8,11 @@ import {
v1UndeployWorkflowContract,
} from '@/lib/api/contracts/v1/workflows'
import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server'
+import { statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration'
-import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import {
checkRateLimit,
diff --git a/apps/sim/app/api/v1/workflows/[id]/export/route.ts b/apps/sim/app/api/v1/workflows/[id]/export/route.ts
index b445e81a3f1..f7ee32c26cb 100644
--- a/apps/sim/app/api/v1/workflows/[id]/export/route.ts
+++ b/apps/sim/app/api/v1/workflows/[id]/export/route.ts
@@ -4,16 +4,10 @@ import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
-import type { Edge } from 'reactflow'
-import {
- type V1WorkflowExportPayload,
- v1ExportWorkflowContract,
-} from '@/lib/api/contracts/v1/workflows'
+import { v1ExportWorkflowContract } from '@/lib/api/contracts/v1/workflows'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
-import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer'
-import { parseWorkflowVariables } from '@/lib/workflows/variables/parse'
+import { buildWorkflowExportPayload } from '@/lib/workflows/operations/export-workflow'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import {
checkRateLimit,
@@ -26,70 +20,14 @@ const logger = createLogger('V1WorkflowExportAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
-type ExportedEdge = V1WorkflowExportPayload['state']['edges'][number]
-
-/**
- * Projects a persisted ReactFlow edge onto the wire shape declared by the
- * response contract. Field-by-field rather than a spread because ReactFlow's
- * `Edge` is looser than the contract in three places: handles are `null` when
- * unset (the contract and the importer both model absent as `undefined`),
- * `label` is a `ReactNode`, and the marker fields accept an `EdgeMarker`
- * object. Non-serializable values in those slots are dropped rather than
- * emitted as `{}`.
- */
-function toExportedEdge(edge: Edge): ExportedEdge {
- return {
- id: edge.id,
- source: edge.source,
- target: edge.target,
- sourceHandle: edge.sourceHandle ?? undefined,
- targetHandle: edge.targetHandle ?? undefined,
- type: edge.type,
- animated: edge.animated,
- style: edge.style as Record | undefined,
- data: edge.data,
- label: typeof edge.label === 'string' ? edge.label : undefined,
- labelStyle: edge.labelStyle as Record | undefined,
- labelShowBg: edge.labelShowBg,
- labelBgStyle: edge.labelBgStyle as Record | undefined,
- labelBgPadding: edge.labelBgPadding,
- labelBgBorderRadius: edge.labelBgBorderRadius,
- markerStart: typeof edge.markerStart === 'string' ? edge.markerStart : undefined,
- markerEnd: typeof edge.markerEnd === 'string' ? edge.markerEnd : undefined,
- }
-}
-
/**
* GET /api/v1/workflows/[id]/export
*
* Exports a workflow as a portable JSON envelope that
- * `POST /api/v1/workflows/import` accepts verbatim.
- *
- * Unlike the admin export (`/api/v1/admin/workflows/[id]/export`), which emits
- * the raw state for backup/restore, this surface runs the payload through
- * `sanitizeForExport`, which nulls three classes of sub-block value:
- * - `password: true` fields, unless the value is a whole `{{ENV_VAR}}`
- * reference, which is preserved so the import resolves it in the target
- * workspace;
- * - `oauth-input` credentials;
- * - **workspace-scoped bindings** — `knowledge-base-selector`, `file-selector`,
- * `channel-selector`, `project-selector`, `folder-selector`,
- * `mcp-server-selector` and friends, plus fields keyed `knowledgeBaseId`,
- * `fileId`, `channelId`, `projectId`, `documentId`, `tagFilters`. These point
- * at rows that do not exist in another workspace, so they are cleared rather
- * than carried across as dangling ids.
- *
- * The last class means an export is **not** a byte-for-byte clone even when
- * re-imported into the same workspace: those bindings come back empty and must
- * be re-selected. This matches the in-app export and is documented on the
- * public endpoint so callers do not expect otherwise.
- *
- * Workflow **variables** are emitted as stored, matching `GET
- * /api/v1/workflows/[id]` and the in-app export. Variables are plaintext
- * workflow configuration readable by anyone with workspace read (the same
- * permission this route requires); secrets belong in environment variables,
- * which travel as unresolved `{{ENV_VAR}}` references. Redacting them here
- * would break import round-trips without narrowing access.
+ * `POST /api/v1/workflows/import` accepts verbatim. Payload assembly and the
+ * sanitization guarantees are documented on the shared
+ * {@link buildWorkflowExportPayload}; this route authenticates and renders the
+ * v1 envelope.
*/
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
@@ -122,45 +60,11 @@ export const GET = withRouteHandler(
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
- const normalizedData = await loadWorkflowFromNormalizedTables(id)
- if (!normalizedData) {
+ const payload = await buildWorkflowExportPayload(workflowData)
+ if (!payload) {
return NextResponse.json({ error: 'Workflow state not found' }, { status: 404 })
}
- const sanitized = sanitizeForExport({
- blocks: normalizedData.blocks,
- edges: normalizedData.edges,
- loops: normalizedData.loops,
- parallels: normalizedData.parallels,
- metadata: {
- name: workflowData.name,
- description: workflowData.description ?? undefined,
- },
- variables: parseWorkflowVariables(workflowData.variables),
- })
-
- const payload: V1WorkflowExportPayload = {
- version: '1.0',
- exportedAt: sanitized.exportedAt,
- workflow: {
- id: workflowData.id,
- name: workflowData.name,
- description: workflowData.description,
- workspaceId: workflowData.workspaceId,
- folderId: workflowData.folderId,
- },
- state: {
- ...sanitized.state,
- edges: sanitized.state.edges.map(toExportedEdge),
- metadata: {
- ...sanitized.state.metadata,
- name: workflowData.name,
- description: workflowData.description ?? undefined,
- exportedAt: sanitized.exportedAt,
- },
- },
- }
-
recordAudit({
workspaceId: workflowData.workspaceId,
actorId: userId,
diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts
index a0779babf51..d015ba4b024 100644
--- a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts
+++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts
@@ -7,10 +7,10 @@ import {
v1RollbackWorkflowContract,
} from '@/lib/api/contracts/v1/workflows'
import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server'
+import { statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { performActivateVersion } from '@/lib/workflows/orchestration'
-import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types'
import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import {
diff --git a/apps/sim/app/api/v1/workflows/import/route.ts b/apps/sim/app/api/v1/workflows/import/route.ts
index dc602b987db..762be48852c 100644
--- a/apps/sim/app/api/v1/workflows/import/route.ts
+++ b/apps/sim/app/api/v1/workflows/import/route.ts
@@ -1,32 +1,17 @@
-import { db } from '@sim/db'
-import { workflow, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import {
- assertFolderInWorkspace,
- assertFolderMutable,
- FolderLockedError,
- FolderNotFoundError,
-} from '@sim/platform-authz/workflow'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
-import { truncate } from '@sim/utils/string'
-import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
- V1_IMPORT_DESCRIPTION_MAX_LENGTH,
- V1_IMPORT_NAME_MAX_LENGTH,
type V1ImportWorkflowData,
v1ImportWorkflowContract,
} from '@/lib/api/contracts/v1/workflows'
-import { workflowStateSchema } from '@/lib/api/contracts/workflows'
-import { parseRequest, serializeZodIssues } from '@/lib/api/server'
+import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
-import { performCreateWorkflow } from '@/lib/workflows/orchestration'
-import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence'
-import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state'
-import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
-import { normalizeImportedVariables } from '@/lib/workflows/variables/parse'
+import {
+ importWorkflowIntoWorkspace,
+ MAX_IMPORT_BODY_BYTES,
+} from '@/lib/workflows/operations/import-workflow'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import {
checkRateLimit,
@@ -34,115 +19,19 @@ import {
v1ValidationErrorResponse,
validateWorkspaceAccess,
} from '@/app/api/v1/middleware'
-import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('V1WorkflowImportAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
-/**
- * Workflow JSON is a bounded document — a few hundred blocks at the outside.
- * Capping well below the platform-wide `DEFAULT_MAX_JSON_BODY_BYTES` (50 MB)
- * keeps a hostile caller from buffering a large body before validation runs.
- */
-const MAX_IMPORT_BODY_BYTES = 10 * 1024 * 1024
-
-const DEFAULT_IMPORTED_WORKFLOW_NAME = 'Imported Workflow'
-
-const TRUNCATION_SUFFIX = '...'
-
-/**
- * Caps a payload-derived string at `maxLength` *including* the ellipsis.
- * `truncate` appends its suffix after slicing, so passing the limit straight
- * through would yield `maxLength + 3` characters and overshoot the very bound
- * this is enforcing.
- */
-function capLength(value: string, maxLength: number): string {
- return truncate(value, maxLength - TRUNCATION_SUFFIX.length, TRUNCATION_SUFFIX)
-}
-
-/**
- * Reads a dot-delimited path off a parsed payload and returns it only when it
- * is a non-empty string, so blank metadata falls through to the next candidate.
- */
-function readString(source: unknown, path: string): string | undefined {
- let current: unknown = source
- for (const segment of path.split('.')) {
- if (!current || typeof current !== 'object') return undefined
- current = (current as Record)[segment]
- }
- return typeof current === 'string' && current.trim() ? current.trim() : undefined
-}
-
-/**
- * Unwraps the `{ data: ... }` response envelope the export endpoint returns, so
- * a caller can pipe an export response body straight into import.
- * `parseWorkflowJson` already tolerates this shape when reading the graph;
- * mirroring it here keeps metadata resolution from silently falling back to the
- * default name for the same payload.
- */
-function unwrapResponseEnvelope(payload: unknown): unknown {
- if (!payload || typeof payload !== 'object') return payload
- const inner = (payload as Record).data
- if (!inner || typeof inner !== 'object') return payload
- const candidate = inner as Record
- return candidate.state || candidate.version || candidate.workflow ? candidate : payload
-}
-
-/**
- * Resolves the imported workflow's name and description, preferring explicit
- * request overrides and then the payload's own metadata. Accepts every shape
- * the importer takes: the export envelope (`workflow.*`, `state.metadata.*`)
- * and a bare state (`metadata.*`).
- *
- * Candidate order deliberately matches `extractWorkflowName` — the resolver the
- * in-app importer has always used — so the same payload yields the same name on
- * both surfaces. The in-app version additionally falls back to the uploaded
- * filename, which has no analogue here; that is the only intended difference.
- *
- * Payload-derived values are capped at the same bounds the contract applies to
- * the explicit overrides, otherwise the declared `maxLength` would not be the
- * effective one — a caller could store an unbounded name simply by embedding it
- * in the payload instead of passing it as a field.
- */
-function resolveImportedMetadata(
- rawPayload: unknown,
- overrideName?: string,
- overrideDescription?: string
-): { name: string; description: string } {
- const payload = unwrapResponseEnvelope(rawPayload)
-
- const name =
- overrideName ||
- capLength(
- readString(payload, 'state.metadata.name') ||
- readString(payload, 'workflow.name') ||
- readString(payload, 'metadata.name') ||
- DEFAULT_IMPORTED_WORKFLOW_NAME,
- V1_IMPORT_NAME_MAX_LENGTH
- )
-
- const description =
- overrideDescription ??
- capLength(
- readString(payload, 'state.metadata.description') ??
- readString(payload, 'workflow.description') ??
- readString(payload, 'metadata.description') ??
- '',
- V1_IMPORT_DESCRIPTION_MAX_LENGTH
- )
-
- return { name, description }
-}
-
/**
* POST /api/v1/workflows/import
*
* Creates a new workflow in the target workspace from an export payload
- * produced by `GET /api/v1/workflows/{id}/export`. Block, edge, loop and
- * parallel ids are regenerated on import, so the same payload can be imported
- * repeatedly and alongside its source workflow without collisions.
+ * produced by `GET /api/v1/workflows/{id}/export`. The shared
+ * {@link importWorkflowIntoWorkspace} pipeline does the heavy lifting; this
+ * route authenticates and renders the v1 envelope.
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateId().slice(0, 8)
@@ -166,12 +55,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
if (!parsed.success) return parsed.response
- const {
- workspaceId,
- folderId,
- name: overrideName,
- description: overrideDescription,
- } = parsed.data.body
+ const { workspaceId, folderId, name, description } = parsed.data.body
logger.info(`[${requestId}] Importing workflow into workspace ${workspaceId}`, {
userId,
@@ -181,191 +65,31 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
if (accessError) return accessError
- const [workspaceData] = await db
- .select({ id: workspace.id })
- .from(workspace)
- .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
- .limit(1)
-
- if (!workspaceData) {
- return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
- }
-
- /**
- * Ownership before lock state: `assertFolderMutable` walks the folder's
- * ancestor chain without filtering on workspace, so checking it first would
- * let a caller distinguish a locked folder in someone else's workspace
- * (423) from a nonexistent one (404).
- */
- if (folderId) {
- await assertFolderInWorkspace(folderId, workspaceId)
- }
- await assertFolderMutable(folderId ?? null)
-
- const rawWorkflow = parsed.data.body.workflow
- const workflowContent =
- typeof rawWorkflow === 'string' ? rawWorkflow : JSON.stringify(rawWorkflow)
-
- const { data: parsedState, errors } = parseWorkflowJson(workflowContent)
- if (!parsedState || errors.length > 0) {
- return NextResponse.json({ error: `Invalid workflow: ${errors.join(', ')}` }, { status: 400 })
- }
-
- /**
- * Variables are normalized before validation, not after: older exports
- * carry them as an array, which is a shape `workflowStateSchema` rightly
- * rejects but the importer has always accepted. Normalizing first keeps
- * that tolerance while still validating what actually gets persisted.
- */
- const variables = normalizeImportedVariables(parsedState.variables)
-
- /**
- * `parseWorkflowJson` only checks that blocks/edges are structurally
- * present. The normalized tables are read back through
- * {@link workflowStateSchema}, and the client parses that response
- * strictly — so a block field with the wrong type (`data.extent: 'child'`,
- * `data.count: '5'`) would persist happily here and then throw on every
- * subsequent load, leaving a workflow nothing can open. Gate on the same
- * schema the canonical `PUT /api/workflows/[id]/state` path enforces.
- */
- const stateValidation = workflowStateSchema.safeParse({ ...parsedState, variables })
- if (!stateValidation.success) {
- const issue = stateValidation.error.issues[0]
- const path = issue?.path.join('.')
- return NextResponse.json(
- {
- error: `Invalid workflow state${path ? ` at ${path}` : ''}: ${issue?.message ?? 'validation failed'}`,
- details: serializeZodIssues(stateValidation.error),
- },
- { status: 400 }
- )
- }
-
- /**
- * Same normalization the editor's `PUT /api/workflows/[id]/state` runs, via
- * the one shared implementation — the two import surfaces must land
- * byte-identical data for the same payload.
- */
- const { state: preparedState, warnings } = prepareWorkflowStateForPersistence(parsedState)
- if (warnings.length > 0) {
- logger.warn(`[${requestId}] Normalized imported workflow with warnings`, { warnings })
- }
-
- const workflowState: WorkflowState = { ...parsedState, ...preparedState }
-
- let parsedPayload: unknown = rawWorkflow
- if (typeof rawWorkflow === 'string') {
- try {
- parsedPayload = JSON.parse(rawWorkflow)
- } catch {
- parsedPayload = undefined
- }
- }
-
- const { name, description } = resolveImportedMetadata(
- parsedPayload,
- overrideName,
- overrideDescription
- )
-
- const created = await performCreateWorkflow({
- name,
- description,
+ const result = await importWorkflowIntoWorkspace({
workspaceId,
folderId,
- deduplicate: true,
+ name,
+ description,
+ workflow: parsed.data.body.workflow,
userId,
requestId,
})
- if (!created.success || !created.workflow) {
- const status =
- created.errorCode === 'conflict' ? 409 : created.errorCode === 'validation' ? 400 : 500
- return NextResponse.json({ error: created.error }, { status })
- }
-
- const workflowId = created.workflow.id
-
- /**
- * The graph and the variables are written in one transaction so an import
- * can never half-land, and any failure deletes the shell row created above
- * — a caller that receives an error must not be left with a partially
- * imported workflow in their workspace.
- */
- try {
- await db.transaction(async (tx) => {
- const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState, tx)
- if (!saveResult.success) {
- throw new Error(saveResult.error || 'Failed to save workflow state')
- }
-
- if (Object.keys(variables).length > 0) {
- await tx
- .update(workflow)
- .set({ variables, updatedAt: new Date() })
- .where(eq(workflow.id, workflowId))
- }
- })
- } catch (error) {
- logger.error(`[${requestId}] Failed to persist imported workflow, rolling back`, {
- workflowId,
- error: getErrorMessage(error, 'Unknown error'),
- })
- /**
- * The rollback runs under the same conditions that just failed the write,
- * so it can fail too. Losing it must not turn into an unlogged orphan:
- * the caller still gets a 500, but the id is recorded loudly enough to
- * clean up.
- */
- try {
- await db.delete(workflow).where(eq(workflow.id, workflowId))
- } catch (rollbackError) {
- logger.error(
- `[${requestId}] Rollback failed, workflow ${workflowId} is orphaned in workspace ${workspaceId}`,
- { workflowId, workspaceId, error: getErrorMessage(rollbackError, 'Unknown error') }
- )
- }
- return NextResponse.json({ error: 'Failed to save workflow state' }, { status: 500 })
- }
-
- /**
- * Matches the canonical state-write path: agent blocks may carry inline
- * custom-tool definitions that must exist as workspace rows to be
- * resolvable at execution. Failures are logged, not fatal — the workflow
- * itself imported successfully.
- */
- try {
- const { saved, errors: toolErrors } = await extractAndPersistCustomTools(
- workflowState,
- workspaceId,
- userId
+ if (!result.success) {
+ return NextResponse.json(
+ { error: result.error, ...(result.details !== undefined && { details: result.details }) },
+ { status: result.status }
)
- if (saved > 0 || toolErrors.length > 0) {
- logger.info(`[${requestId}] Persisted ${saved} custom tool(s) from import`, {
- workflowId,
- errors: toolErrors,
- })
- }
- } catch (error) {
- logger.error(`[${requestId}] Failed to persist custom tools from import`, {
- workflowId,
- error: getErrorMessage(error, 'Unknown error'),
- })
}
- logger.info(`[${requestId}] Imported workflow ${workflowId} into workspace ${workspaceId}`, {
- name: created.workflow.name,
- blocksCount: Object.keys(workflowState.blocks).length,
- })
-
const data: V1ImportWorkflowData = {
- id: workflowId,
- name: created.workflow.name,
- description: created.workflow.description ?? null,
- workspaceId,
- folderId: created.workflow.folderId ?? null,
- createdAt: created.workflow.createdAt.toISOString(),
- updatedAt: created.workflow.updatedAt.toISOString(),
+ id: result.workflow.id,
+ name: result.workflow.name,
+ description: result.workflow.description,
+ workspaceId: result.workflow.workspaceId,
+ folderId: result.workflow.folderId,
+ createdAt: result.workflow.createdAt.toISOString(),
+ updatedAt: result.workflow.updatedAt.toISOString(),
}
const limits = await getUserLimits(userId)
@@ -373,9 +97,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json(apiResponse.body, { status: 201, headers: apiResponse.headers })
} catch (error: unknown) {
- if (error instanceof FolderLockedError || error instanceof FolderNotFoundError) {
- return NextResponse.json({ error: error.message }, { status: error.status })
- }
const message = getErrorMessage(error, 'Unknown error')
logger.error(`[${requestId}] Workflow import error`, { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
diff --git a/apps/sim/app/api/v2/audit-logs/[id]/route.ts b/apps/sim/app/api/v2/audit-logs/[id]/route.ts
new file mode 100644
index 00000000000..65a270342fa
--- /dev/null
+++ b/apps/sim/app/api/v2/audit-logs/[id]/route.ts
@@ -0,0 +1,80 @@
+import { db } from '@sim/db'
+import { auditLog } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { and, eq } from 'drizzle-orm'
+import type { NextRequest } from 'next/server'
+import { v2GetAuditLogContract } from '@/lib/api/contracts/v2/audit-logs'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
+import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query'
+import { checkRateLimit } from '@/app/api/v1/middleware'
+import { formatV2AuditLogEntry } from '@/app/api/v2/audit-logs/format'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2AuditLogDetailAPI')
+
+export const revalidate = 0
+
+/**
+ * GET /api/v2/audit-logs/[id]
+ *
+ * Returns a single audit log entry scoped to the authenticated user's
+ * organization. Org-scoped (not workspace-scoped). Unlike v1, authorization
+ * (`checkRateLimit` → `validateEnterpriseAuditAccess`) runs BEFORE the untrusted
+ * param is parsed, fixing the v1 ordering inconsistency. The org-scope predicate
+ * is folded into the lookup so a non-org log reads as 404 (existence is not
+ * leaked).
+ */
+export const GET = withRouteHandler(
+ async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'audit-logs')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const authResult = await resolveEnterpriseAuditAccess(userId)
+ if (!authResult.success) return v2Error('FORBIDDEN', authResult.message)
+
+ const parsed = await parseRequest(v2GetAuditLogContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+ const { organizationId, orgMemberIds } = authResult.context
+
+ const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId)
+ const scopeCondition = buildOrgScopeCondition({
+ organizationId,
+ orgWorkspaceIds,
+ orgMemberIds,
+ includeDeparted: true,
+ })
+
+ const [log] = await db
+ .select()
+ .from(auditLog)
+ .where(and(eq(auditLog.id, id), scopeCondition))
+ .limit(1)
+
+ if (!log) return v2Error('NOT_FOUND', 'Audit log not found')
+
+ return v2Data(formatV2AuditLogEntry(log), { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Audit log detail fetch error`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/audit-logs/format.test.ts b/apps/sim/app/api/v2/audit-logs/format.test.ts
new file mode 100644
index 00000000000..cece8dd51df
--- /dev/null
+++ b/apps/sim/app/api/v2/audit-logs/format.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest'
+import { formatV2AuditLogEntry } from '@/app/api/v2/audit-logs/format'
+
+describe('formatV2AuditLogEntry', () => {
+ it('removes internal folder identifiers recursively from the public projection', () => {
+ const formatted = formatV2AuditLogEntry({
+ id: 'audit-1',
+ workspaceId: 'workspace-1',
+ actorId: 'user-1',
+ actorName: 'Teddy',
+ actorEmail: 'teddy@example.com',
+ action: 'folder.moved',
+ resourceType: 'folder',
+ resourceId: 'internal-folder-id',
+ resourceName: 'Reports',
+ description: 'Moved Reports',
+ metadata: {
+ folderId: 'internal-folder-id',
+ targetFolderId: 'internal-target-id',
+ nested: { tableImportFolderId: 'internal-import-id', path: '/Reports' },
+ },
+ createdAt: new Date('2024-01-01T00:00:00Z'),
+ })
+
+ expect(formatted.resourceId).toBeNull()
+ expect(formatted.metadata).toEqual({ nested: { path: '/Reports' } })
+ })
+})
diff --git a/apps/sim/app/api/v2/audit-logs/format.ts b/apps/sim/app/api/v2/audit-logs/format.ts
new file mode 100644
index 00000000000..b1e7fb88b7c
--- /dev/null
+++ b/apps/sim/app/api/v2/audit-logs/format.ts
@@ -0,0 +1,57 @@
+import type { auditLog } from '@sim/db/schema'
+import { isRecordLike } from '@sim/utils/object'
+import type { InferSelectModel } from 'drizzle-orm'
+
+type DbAuditLog = Pick<
+ InferSelectModel,
+ | 'id'
+ | 'workspaceId'
+ | 'actorId'
+ | 'actorName'
+ | 'actorEmail'
+ | 'action'
+ | 'resourceType'
+ | 'resourceId'
+ | 'resourceName'
+ | 'description'
+ | 'metadata'
+ | 'createdAt'
+>
+
+const INTERNAL_FOLDER_ID_KEYS = new Set([
+ 'folderId',
+ 'folderIds',
+ 'parentId',
+ 'tableImportFolderId',
+ 'targetFolderId',
+])
+
+function sanitizeMetadata(value: unknown): unknown {
+ if (Array.isArray(value)) return value.map(sanitizeMetadata)
+ if (!isRecordLike(value)) return value
+
+ const sanitized: Record = {}
+ for (const [key, child] of Object.entries(value)) {
+ if (INTERNAL_FOLDER_ID_KEYS.has(key)) continue
+ sanitized[key] = sanitizeMetadata(child)
+ }
+ return sanitized
+}
+
+/** Removes database folder identifiers from the public v2 audit projection. */
+export function formatV2AuditLogEntry(log: DbAuditLog) {
+ return {
+ id: log.id,
+ workspaceId: log.workspaceId,
+ actorId: log.actorId,
+ actorName: log.actorName,
+ actorEmail: log.actorEmail,
+ action: log.action,
+ resourceType: log.resourceType,
+ resourceId: log.resourceType === 'folder' ? null : log.resourceId,
+ resourceName: log.resourceName,
+ description: log.description,
+ metadata: sanitizeMetadata(log.metadata),
+ createdAt: log.createdAt.toISOString(),
+ }
+}
diff --git a/apps/sim/app/api/v2/audit-logs/route.ts b/apps/sim/app/api/v2/audit-logs/route.ts
new file mode 100644
index 00000000000..32ef339a8f9
--- /dev/null
+++ b/apps/sim/app/api/v2/audit-logs/route.ts
@@ -0,0 +1,107 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import type { NextRequest } from 'next/server'
+import { v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
+import {
+ buildFilterConditions,
+ buildOrgScopeCondition,
+ getOrgWorkspaceIds,
+ queryAuditLogs,
+} from '@/app/api/v1/audit-logs/query'
+import { checkRateLimit } from '@/app/api/v1/middleware'
+import { formatV2AuditLogEntry } from '@/app/api/v2/audit-logs/format'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CursorList,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2AuditLogsAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+/**
+ * GET /api/v2/audit-logs
+ *
+ * Lists audit logs scoped to the authenticated user's organization. Org-scoped
+ * (not workspace-scoped): `resolveWorkspaceAccess` is intentionally NOT used —
+ * access is gated by enterprise org admin/owner membership. Auth ordering
+ * matches v1: `checkRateLimit` → `validateEnterpriseAuditAccess` run before the
+ * untrusted query is parsed.
+ */
+export const GET = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'audit-logs')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const authResult = await resolveEnterpriseAuditAccess(userId)
+ if (!authResult.success) return v2Error('FORBIDDEN', authResult.message)
+
+ const { organizationId, orgMemberIds } = authResult.context
+
+ const parsed = await parseRequest(
+ v2ListAuditLogsContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ const params = parsed.data.query
+
+ if (params.actorId && !orgMemberIds.includes(params.actorId)) {
+ return v2Error('BAD_REQUEST', 'actorId is not a member of your organization')
+ }
+
+ const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId)
+
+ if (params.workspaceId && !orgWorkspaceIds.includes(params.workspaceId)) {
+ return v2Error('BAD_REQUEST', 'workspaceId does not belong to your organization')
+ }
+
+ const scopeCondition = buildOrgScopeCondition({
+ organizationId,
+ orgWorkspaceIds,
+ orgMemberIds,
+ includeDeparted: params.includeDeparted,
+ })
+ const filterConditions = buildFilterConditions({
+ action: params.action,
+ resourceType: params.resourceType,
+ resourceId: params.resourceId,
+ workspaceId: params.workspaceId,
+ actorId: params.actorId,
+ startDate: params.startDate,
+ endDate: params.endDate,
+ })
+
+ const { data, nextCursor } = await queryAuditLogs(
+ [scopeCondition, ...filterConditions],
+ params.limit,
+ params.cursor
+ )
+
+ return v2CursorList(data.map(formatV2AuditLogEntry), nextCursor ?? null, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Audit logs fetch error`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/billing/usage/logs/route.test.ts b/apps/sim/app/api/v2/billing/usage/logs/route.test.ts
new file mode 100644
index 00000000000..ed7d0390381
--- /dev/null
+++ b/apps/sim/app/api/v2/billing/usage/logs/route.test.ts
@@ -0,0 +1,135 @@
+/**
+ * @vitest-environment node
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { apportionCredits } from '@/lib/billing/credits/conversion'
+
+const { mockCheckRateLimit, mockGetUserUsageLogs, mockGetUsageCreditsByLogId } = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockGetUserUsageLogs: vi.fn(),
+ mockGetUsageCreditsByLogId: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+}))
+
+vi.mock('@/lib/billing/core/usage-log', () => ({
+ getUserUsageLogs: mockGetUserUsageLogs,
+ getUsageCreditsByLogId: mockGetUsageCreditsByLogId,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+import { GET } from '@/app/api/v2/billing/usage/logs/route'
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'personal',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2026-01-01T01:00:00Z'),
+}
+
+function callLogs(query = '') {
+ return GET(new NextRequest(`http://localhost:3000/api/v2/billing/usage/logs${query}`))
+}
+
+describe('GET /api/v2/billing/usage/logs', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockGetUserUsageLogs.mockResolvedValue({
+ logs: [
+ {
+ id: 'log-1',
+ createdAt: '2026-07-01T00:00:00.000Z',
+ category: 'model',
+ source: 'copilot',
+ description: 'claude-sonnet',
+ cost: 0.06,
+ },
+ ],
+ summary: { totalCost: 0, bySource: {} },
+ pagination: { hasMore: false },
+ })
+ mockGetUsageCreditsByLogId.mockResolvedValue(
+ apportionCredits([{ key: 'log-1', dollars: 0.06 }])
+ )
+ })
+
+ it('returns credit-denominated rows in the cursor envelope, no dollar costs', async () => {
+ const res = await callLogs()
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.nextCursor).toBeNull()
+ expect(body.data).toEqual([
+ {
+ id: 'log-1',
+ createdAt: '2026-07-01T00:00:00.000Z',
+ source: 'copilot',
+ workflowName: null,
+ creditCost: 12,
+ },
+ ])
+ expect(JSON.stringify(body)).not.toContain('ollarCost')
+ })
+
+ it('forwards the cursor when more rows remain', async () => {
+ mockGetUserUsageLogs.mockResolvedValue({
+ logs: [],
+ summary: { totalCost: 0, bySource: {} },
+ pagination: { hasMore: true, nextCursor: 'log-42' },
+ })
+ mockGetUsageCreditsByLogId.mockResolvedValue({})
+ const body = await (await callLogs()).json()
+ expect(body.nextCursor).toBe('log-42')
+ })
+
+ it('pins a workspace API key to its own workspace', async () => {
+ mockCheckRateLimit.mockResolvedValue({
+ ...RATE_LIMIT_OK,
+ keyType: 'workspace',
+ workspaceId: 'ws-1',
+ })
+ const res = await callLogs()
+ expect(res.status).toBe(200)
+ expect(mockGetUserUsageLogs).toHaveBeenCalledWith(
+ 'user-1',
+ expect.objectContaining({ workspaceId: 'ws-1' })
+ )
+ })
+
+ it('403s a workspace API key asking for a different workspace', async () => {
+ mockCheckRateLimit.mockResolvedValue({
+ ...RATE_LIMIT_OK,
+ keyType: 'workspace',
+ workspaceId: 'ws-1',
+ })
+ const res = await callLogs('?workspaceId=ws-2')
+ expect(res.status).toBe(403)
+ expect(mockGetUserUsageLogs).not.toHaveBeenCalled()
+ })
+
+ it('rejects "custom" period without a startDate', async () => {
+ const res = await callLogs('?period=custom')
+ expect(res.status).toBe(400)
+ expect(mockGetUserUsageLogs).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue({
+ allowed: false,
+ limit: 100,
+ remaining: 0,
+ resetAt: new Date('2026-01-01T01:00:00Z'),
+ retryAfterMs: 1000,
+ })
+ const res = await callLogs()
+ expect(res.status).toBe(429)
+ })
+})
diff --git a/apps/sim/app/api/v2/billing/usage/logs/route.ts b/apps/sim/app/api/v2/billing/usage/logs/route.ts
new file mode 100644
index 00000000000..93621a9606b
--- /dev/null
+++ b/apps/sim/app/api/v2/billing/usage/logs/route.ts
@@ -0,0 +1,92 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { v2ListUsageLogsContract } from '@/lib/api/contracts/v2/billing'
+import { parseRequest } from '@/lib/api/server'
+import {
+ getUsageCreditsByLogId,
+ getUserUsageLogs,
+ type UsageLogSource,
+} from '@/lib/billing/core/usage-log'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared'
+import { checkRateLimit } from '@/app/api/v1/middleware'
+import { v2BillingWorkspaceFilter } from '@/app/api/v2/billing/utils'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CursorList,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2BillingUsageLogsAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+/**
+ * GET /api/v2/billing/usage/logs — Cursor-paged, credit-denominated ledger of
+ * the account's usage events. The per-source aggregate lives on
+ * `GET /api/v2/billing/usage`; this is the row-level detail.
+ */
+export const GET = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'billing-usage')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2ListUsageLogsContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+ const { source, workspaceId, period, startDate, endDate, limit, cursor } = parsed.data.query
+
+ const workspaceFilter = v2BillingWorkspaceFilter(rateLimit, workspaceId)
+ if (!workspaceFilter.ok) return workspaceFilter.response
+
+ const dateRange = resolveDateRange(period, startDate, endDate)
+ const filter = {
+ source: source as UsageLogSource | undefined,
+ workspaceId: workspaceFilter.workspaceId,
+ startDate: dateRange.startDate,
+ endDate: dateRange.endDate,
+ }
+
+ const [result, creditsByLogId] = await Promise.all([
+ getUserUsageLogs(userId, { ...filter, limit, cursor, includeSummary: false }),
+ getUsageCreditsByLogId(userId, filter),
+ ])
+
+ const items = result.logs.map((log) => ({
+ id: log.id,
+ createdAt: log.createdAt,
+ source: log.source,
+ workflowName: log.workflowName ?? null,
+ creditCost: creditsByLogId[log.id] ?? 0,
+ }))
+
+ return v2CursorList(
+ items,
+ result.pagination.hasMore ? (result.pagination.nextCursor ?? null) : null,
+ { rateLimit }
+ )
+ } catch (error) {
+ logger.error(`[${requestId}] Error listing usage logs`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/billing/usage/route.test.ts b/apps/sim/app/api/v2/billing/usage/route.test.ts
new file mode 100644
index 00000000000..5e2a63f5adf
--- /dev/null
+++ b/apps/sim/app/api/v2/billing/usage/route.test.ts
@@ -0,0 +1,144 @@
+/**
+ * @vitest-environment node
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockGetUserUsageLogs,
+ mockCheckServerSideUsageLimits,
+ mockGetHighestPrioritySubscription,
+ mockDeriveBillingContext,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockGetUserUsageLogs: vi.fn(),
+ mockCheckServerSideUsageLimits: vi.fn(),
+ mockGetHighestPrioritySubscription: vi.fn(),
+ mockDeriveBillingContext: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+}))
+
+vi.mock('@/lib/billing', () => ({
+ checkServerSideUsageLimits: mockCheckServerSideUsageLimits,
+}))
+
+vi.mock('@/lib/billing/core/subscription', () => ({
+ getHighestPrioritySubscription: mockGetHighestPrioritySubscription,
+}))
+
+vi.mock('@/lib/billing/core/usage-log', () => ({
+ deriveBillingContext: mockDeriveBillingContext,
+ getUserUsageLogs: mockGetUserUsageLogs,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+import { GET } from '@/app/api/v2/billing/usage/route'
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'personal',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2026-01-01T01:00:00Z'),
+}
+
+function callSummary(query = '') {
+ return GET(new NextRequest(`http://localhost:3000/api/v2/billing/usage${query}`))
+}
+
+describe('GET /api/v2/billing/usage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro' })
+ mockDeriveBillingContext.mockReturnValue({
+ billingEntity: { type: 'user', id: 'user-1' },
+ billingPeriod: {
+ start: new Date('2026-07-01T00:00:00Z'),
+ end: new Date('2026-08-01T00:00:00Z'),
+ },
+ })
+ mockCheckServerSideUsageLimits.mockResolvedValue({
+ isExceeded: false,
+ currentUsage: 2.5,
+ limit: 100,
+ })
+ mockGetUserUsageLogs.mockResolvedValue({
+ logs: [],
+ summary: { totalCost: 2.5, bySource: { workflow: 1.9, copilot: 0.6 } },
+ pagination: { hasMore: false },
+ })
+ })
+
+ it('returns the billing-period summary with per-source credits, no dollars', async () => {
+ const res = await callSummary()
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.data).toEqual({
+ period: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' },
+ totalCredits: 500,
+ bySourceCredits: { workflow: 380, copilot: 120 },
+ limitCredits: 20000,
+ plan: 'pro',
+ })
+ expect(JSON.stringify(body)).not.toContain('dollar')
+ })
+
+ it('queries the ledger summary over the derived billing period', async () => {
+ await callSummary()
+ expect(mockGetUserUsageLogs).toHaveBeenCalledWith(
+ 'user-1',
+ expect.objectContaining({
+ startDate: new Date('2026-07-01T00:00:00Z'),
+ endDate: new Date('2026-08-01T00:00:00Z'),
+ includeSummary: true,
+ })
+ )
+ })
+
+ it('pins a workspace API key to its own workspace', async () => {
+ mockCheckRateLimit.mockResolvedValue({
+ ...RATE_LIMIT_OK,
+ keyType: 'workspace',
+ workspaceId: 'ws-1',
+ })
+ const res = await callSummary()
+ expect(res.status).toBe(200)
+ expect(mockGetUserUsageLogs).toHaveBeenCalledWith(
+ 'user-1',
+ expect.objectContaining({ workspaceId: 'ws-1' })
+ )
+ })
+
+ it('403s a workspace API key asking for a different workspace', async () => {
+ mockCheckRateLimit.mockResolvedValue({
+ ...RATE_LIMIT_OK,
+ keyType: 'workspace',
+ workspaceId: 'ws-1',
+ })
+ const res = await callSummary('?workspaceId=ws-2')
+ expect(res.status).toBe(403)
+ expect((await res.json()).error.code).toBe('FORBIDDEN')
+ expect(mockGetUserUsageLogs).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue({
+ allowed: false,
+ limit: 100,
+ remaining: 0,
+ resetAt: new Date('2026-01-01T01:00:00Z'),
+ retryAfterMs: 1000,
+ })
+ const res = await callSummary()
+ expect(res.status).toBe(429)
+ })
+})
diff --git a/apps/sim/app/api/v2/billing/usage/route.ts b/apps/sim/app/api/v2/billing/usage/route.ts
new file mode 100644
index 00000000000..60d826fc5c8
--- /dev/null
+++ b/apps/sim/app/api/v2/billing/usage/route.ts
@@ -0,0 +1,92 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { type V2UsageSummaryData, v2GetUsageSummaryContract } from '@/lib/api/contracts/v2/billing'
+import { parseRequest } from '@/lib/api/server'
+import { checkServerSideUsageLimits } from '@/lib/billing'
+import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
+import { deriveBillingContext, getUserUsageLogs } from '@/lib/billing/core/usage-log'
+import { dollarsToCredits } from '@/lib/billing/credits/conversion'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { checkRateLimit } from '@/app/api/v1/middleware'
+import { v2BillingWorkspaceFilter } from '@/app/api/v2/billing/utils'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2BillingUsageAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+/**
+ * GET /api/v2/billing/usage — Current-billing-period usage summary with the
+ * per-source credit breakdown, for external monitoring (e.g. alerting on
+ * Copilot consumption before an overage). Credits only — dollar costs and
+ * rate-limit internals are not part of this surface.
+ */
+export const GET = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'billing-usage')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2GetUsageSummaryContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ const workspaceFilter = v2BillingWorkspaceFilter(rateLimit, parsed.data.query.workspaceId)
+ if (!workspaceFilter.ok) return workspaceFilter.response
+
+ const subscription = await getHighestPrioritySubscription(userId)
+ const { billingPeriod } = deriveBillingContext(userId, subscription)
+
+ const [usageCheck, ledger] = await Promise.all([
+ checkServerSideUsageLimits(userId, subscription),
+ getUserUsageLogs(userId, {
+ workspaceId: workspaceFilter.workspaceId,
+ startDate: billingPeriod.start,
+ endDate: billingPeriod.end,
+ limit: 1,
+ includeSummary: true,
+ }),
+ ])
+
+ const bySourceCredits = Object.fromEntries(
+ Object.entries(ledger.summary.bySource).map(([source, cost]) => [
+ source,
+ dollarsToCredits(cost),
+ ])
+ )
+
+ const data: V2UsageSummaryData = {
+ period: {
+ start: billingPeriod.start.toISOString(),
+ end: billingPeriod.end.toISOString(),
+ },
+ totalCredits: dollarsToCredits(ledger.summary.totalCost),
+ bySourceCredits,
+ limitCredits: dollarsToCredits(usageCheck.limit),
+ plan: subscription?.plan || 'free',
+ }
+
+ return v2Data(data, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Error building usage summary`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/billing/utils.ts b/apps/sim/app/api/v2/billing/utils.ts
new file mode 100644
index 00000000000..3e7eea9ca70
--- /dev/null
+++ b/apps/sim/app/api/v2/billing/utils.ts
@@ -0,0 +1,30 @@
+import type { NextResponse } from 'next/server'
+import type { RateLimitResult } from '@/app/api/v1/middleware'
+import { v2Error } from '@/app/api/v2/lib/response'
+
+type BillingWorkspaceFilter =
+ | { ok: true; workspaceId: string | undefined }
+ | { ok: false; response: NextResponse }
+
+/**
+ * Resolves the effective `workspaceId` ledger filter for the caller's key.
+ * Personal keys read the account's full ledger with whatever filter they asked
+ * for; a workspace-scoped key is pinned to its own workspace — the filter
+ * defaults to the key's workspace and an explicit mismatch is rejected rather
+ * than silently ignored.
+ */
+export function v2BillingWorkspaceFilter(
+ rateLimit: RateLimitResult,
+ requestedWorkspaceId: string | undefined
+): BillingWorkspaceFilter {
+ if (rateLimit.keyType !== 'workspace') {
+ return { ok: true, workspaceId: requestedWorkspaceId }
+ }
+ if (requestedWorkspaceId && requestedWorkspaceId !== rateLimit.workspaceId) {
+ return {
+ ok: false,
+ response: v2Error('FORBIDDEN', 'API key is not authorized for this workspace'),
+ }
+ }
+ return { ok: true, workspaceId: rateLimit.workspaceId }
+}
diff --git a/apps/sim/app/api/v2/credentials/[id]/route.test.ts b/apps/sim/app/api/v2/credentials/[id]/route.test.ts
new file mode 100644
index 00000000000..b76997daad2
--- /dev/null
+++ b/apps/sim/app/api/v2/credentials/[id]/route.test.ts
@@ -0,0 +1,414 @@
+/**
+ * @vitest-environment node
+ *
+ * Public v2 credential detail: workspace scoping of the id, the 404 mask for a
+ * credential the caller has no membership on, and secret-free reads.
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockResolveWorkspaceAccess,
+ mockGetWorkspaceCredential,
+ mockGetCredentialActorContext,
+ mockPerformUpdateCredential,
+ mockPerformDeleteCredential,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceAccess: vi.fn(),
+ mockGetWorkspaceCredential: vi.fn(),
+ mockGetCredentialActorContext: vi.fn(),
+ mockPerformUpdateCredential: vi.fn(),
+ mockPerformDeleteCredential: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceAccess: mockResolveWorkspaceAccess,
+}))
+
+vi.mock('@/lib/credentials/queries', () => ({
+ getWorkspaceCredential: mockGetWorkspaceCredential,
+}))
+
+vi.mock('@/lib/credentials/access', () => ({
+ getCredentialActorContext: mockGetCredentialActorContext,
+}))
+
+vi.mock('@/lib/credentials/orchestration', async () => {
+ const actual = await import('@/lib/credentials/orchestration/credential-create')
+ return {
+ isProviderOutageCode: actual.isProviderOutageCode,
+ performUpdateCredential: mockPerformUpdateCredential,
+ performDeleteCredential: mockPerformDeleteCredential,
+ }
+})
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+import { DELETE, GET, PATCH } from '@/app/api/v2/credentials/[id]/route'
+
+const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555'
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+}
+
+const RATE_LIMIT_DENIED = {
+ allowed: false,
+ limit: 100,
+ remaining: 0,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+ retryAfterMs: 1000,
+}
+
+const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' }
+
+function buildRow(overrides: Record = {}) {
+ return {
+ id: 'cred_abc123',
+ workspaceId: WORKSPACE_ID,
+ type: 'service_account',
+ displayName: 'Zoom account acct_123',
+ description: null,
+ providerId: 'zoom-service-account',
+ accountId: null,
+ envKey: null,
+ envOwnerUserId: null,
+ encryptedServiceAccountKey: 'encrypted-blob',
+ createdBy: 'user-1',
+ createdAt: new Date('2024-01-01T00:00:00Z'),
+ updatedAt: new Date('2024-01-02T00:00:00Z'),
+ ...overrides,
+ }
+}
+
+const routeContext = () => ({ params: Promise.resolve({ id: 'cred_abc123' }) })
+const url = (query = `workspaceId=${WORKSPACE_ID}`) =>
+ `http://localhost:3000/api/v2/credentials/cred_abc123?${query}`
+
+const callGet = (query?: string) => GET(new NextRequest(url(query)), routeContext())
+const callDelete = (query?: string) =>
+ DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext())
+
+function callPatch(body: unknown) {
+ return PATCH(
+ new NextRequest('http://localhost:3000/api/v2/credentials/cred_abc123', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ }),
+ routeContext()
+ )
+}
+
+describe('GET /api/v2/credentials/[id]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockGetWorkspaceCredential.mockResolvedValue(buildRow())
+ mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'admin' }, isAdmin: true })
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callGet()
+
+ expect(res.status).toBe(404)
+ expect(mockGetWorkspaceCredential).not.toHaveBeenCalled()
+ })
+
+ it('400s when workspaceId is missing', async () => {
+ const res = await callGet('')
+ expect(res.status).toBe(400)
+ expect(mockGetWorkspaceCredential).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED)
+ const res = await callGet()
+ expect(res.status).toBe(403)
+ expect(mockGetWorkspaceCredential).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callGet()
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('404s when the credential belongs to another workspace', async () => {
+ mockGetWorkspaceCredential.mockResolvedValue(null)
+ const res = await callGet()
+ expect(res.status).toBe(404)
+ expect((await res.json()).error.code).toBe('NOT_FOUND')
+ })
+
+ it('masks a credential the caller has no membership on as 404', async () => {
+ mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false })
+ const res = await callGet()
+ expect(res.status).toBe(404)
+ expect((await res.json()).error.code).toBe('NOT_FOUND')
+ })
+
+ it('returns the public shape with no secret material', async () => {
+ const res = await callGet()
+ const body = await res.json()
+
+ expect(res.status).toBe(200)
+ expect(body.data.credential).toEqual({
+ id: 'cred_abc123',
+ type: 'service_account',
+ displayName: 'Zoom account acct_123',
+ description: null,
+ providerId: 'zoom-service-account',
+ accountId: null,
+ envKey: null,
+ hasServiceAccountKey: true,
+ role: 'admin',
+ createdAt: '2024-01-01T00:00:00.000Z',
+ updatedAt: '2024-01-02T00:00:00.000Z',
+ })
+ expect(JSON.stringify(body)).not.toContain('encrypted-blob')
+ })
+})
+
+describe('PATCH /api/v2/credentials/[id]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockGetWorkspaceCredential.mockResolvedValue(buildRow())
+ mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'admin' }, isAdmin: true })
+ mockPerformUpdateCredential.mockResolvedValue({ success: true })
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' })
+
+ expect(res.status).toBe(404)
+ expect(mockPerformUpdateCredential).not.toHaveBeenCalled()
+ })
+
+ it('400s when no field to change is supplied', async () => {
+ const res = await callPatch({ workspaceId: WORKSPACE_ID })
+ expect(res.status).toBe(400)
+ expect(mockPerformUpdateCredential).not.toHaveBeenCalled()
+ })
+
+ it('400s when the body carries an unknown field', async () => {
+ const res = await callPatch({ workspaceId: WORKSPACE_ID, bogus: 'x' })
+ expect(res.status).toBe(400)
+ expect(mockPerformUpdateCredential).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED)
+ const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' })
+ expect(res.status).toBe(403)
+ expect(mockPerformUpdateCredential).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' })
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('404s when the credential belongs to another workspace', async () => {
+ mockGetWorkspaceCredential.mockResolvedValue(null)
+ const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' })
+ expect(res.status).toBe(404)
+ expect(mockPerformUpdateCredential).not.toHaveBeenCalled()
+ })
+
+ it('403s when the caller is not a credential admin', async () => {
+ mockPerformUpdateCredential.mockResolvedValue({
+ success: false,
+ error: 'Credential admin permission required',
+ errorCode: 'forbidden',
+ })
+ const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' })
+ expect(res.status).toBe(403)
+ expect((await res.json()).error.code).toBe('FORBIDDEN')
+ })
+
+ it('gates on workspace read, leaving admin rights to the per-credential check', async () => {
+ await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' })
+ expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(
+ expect.anything(),
+ 'user-1',
+ WORKSPACE_ID,
+ 'read'
+ )
+ })
+
+ it('masks a credential the caller cannot see as 404, not 403', async () => {
+ mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false })
+ const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' })
+ expect(res.status).toBe(404)
+ expect(mockPerformUpdateCredential).not.toHaveBeenCalled()
+ })
+
+ it('503s when the provider is unreachable during a secret rotation', async () => {
+ mockPerformUpdateCredential.mockResolvedValue({
+ success: false,
+ error: 'provider_unavailable',
+ errorCode: 'validation',
+ providerErrorCode: 'provider_unavailable',
+ })
+ const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'tok' })
+ expect(res.status).toBe(503)
+ expect((await res.json()).error.code).toBe('SERVICE_UNAVAILABLE')
+ })
+
+ it('rejects a displayName rename on an env credential instead of dropping it', async () => {
+ mockGetWorkspaceCredential.mockResolvedValue(
+ buildRow({ type: 'env_workspace', envKey: 'STRIPE_API_KEY', displayName: 'STRIPE_API_KEY' })
+ )
+ const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' })
+
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.message).toContain('envKey')
+ expect(mockPerformUpdateCredential).not.toHaveBeenCalled()
+ })
+
+ it('still allows a description change on an env credential', async () => {
+ mockGetWorkspaceCredential.mockResolvedValue(buildRow({ type: 'env_workspace' }))
+ const res = await callPatch({ workspaceId: WORKSPACE_ID, description: 'note' })
+
+ expect(res.status).toBe(200)
+ expect(mockPerformUpdateCredential).toHaveBeenCalled()
+ })
+
+ it('503s on an Atlassian outage too, not just a token-provider one', async () => {
+ mockPerformUpdateCredential.mockResolvedValue({
+ success: false,
+ error: 'atlassian_unavailable',
+ errorCode: 'validation',
+ providerErrorCode: 'atlassian_unavailable',
+ })
+ const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'tok' })
+ expect(res.status).toBe(503)
+ expect((await res.json()).error.code).toBe('SERVICE_UNAVAILABLE')
+ })
+
+ it('keeps a rejected secret a 400, not a 503', async () => {
+ mockPerformUpdateCredential.mockResolvedValue({
+ success: false,
+ error: 'invalid_credentials',
+ errorCode: 'validation',
+ providerErrorCode: 'invalid_credentials',
+ })
+ const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'tok' })
+ expect(res.status).toBe(400)
+ })
+
+ it('rotates a secret without echoing it back', async () => {
+ const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'brand-new-token' })
+ const body = await res.json()
+
+ expect(res.status).toBe(200)
+ expect(JSON.stringify(body)).not.toContain('brand-new-token')
+ expect(mockPerformUpdateCredential).toHaveBeenCalledWith(
+ expect.objectContaining({
+ credentialId: 'cred_abc123',
+ userId: 'user-1',
+ apiToken: 'brand-new-token',
+ })
+ )
+ })
+})
+
+describe('DELETE /api/v2/credentials/[id]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockGetWorkspaceCredential.mockResolvedValue(buildRow())
+ mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'admin' }, isAdmin: true })
+ mockPerformDeleteCredential.mockResolvedValue({ success: true })
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callDelete()
+
+ expect(res.status).toBe(404)
+ expect(mockPerformDeleteCredential).not.toHaveBeenCalled()
+ })
+
+ it('400s when workspaceId is missing', async () => {
+ const res = await callDelete('')
+ expect(res.status).toBe(400)
+ expect(mockPerformDeleteCredential).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED)
+ const res = await callDelete()
+ expect(res.status).toBe(403)
+ expect(mockPerformDeleteCredential).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callDelete()
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('404s when the credential belongs to another workspace', async () => {
+ mockGetWorkspaceCredential.mockResolvedValue(null)
+ const res = await callDelete()
+ expect(res.status).toBe(404)
+ expect(mockPerformDeleteCredential).not.toHaveBeenCalled()
+ })
+
+ it('gates on workspace read, leaving admin rights to the per-credential check', async () => {
+ await callDelete()
+ expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(
+ expect.anything(),
+ 'user-1',
+ WORKSPACE_ID,
+ 'read'
+ )
+ })
+
+ it('masks a credential the caller cannot see as 404, not 403', async () => {
+ mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false })
+ const res = await callDelete()
+ expect(res.status).toBe(404)
+ expect(mockPerformDeleteCredential).not.toHaveBeenCalled()
+ })
+
+ it('deletes the credential and acknowledges the id', async () => {
+ const res = await callDelete()
+ expect(res.status).toBe(200)
+ expect(await res.json()).toEqual({ data: { id: 'cred_abc123', deleted: true } })
+ expect(mockPerformDeleteCredential).toHaveBeenCalledWith(
+ expect.objectContaining({ credentialId: 'cred_abc123', userId: 'user-1' })
+ )
+ })
+})
diff --git a/apps/sim/app/api/v2/credentials/[id]/route.ts b/apps/sim/app/api/v2/credentials/[id]/route.ts
new file mode 100644
index 00000000000..d92c016b284
--- /dev/null
+++ b/apps/sim/app/api/v2/credentials/[id]/route.ts
@@ -0,0 +1,216 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import {
+ v2DeleteCredentialContract,
+ v2GetCredentialContract,
+ v2UpdateCredentialContract,
+} from '@/lib/api/contracts/v2/credentials'
+import { parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getCredentialActorContext } from '@/lib/credentials/access'
+import {
+ isProviderOutageCode,
+ performDeleteCredential,
+ performUpdateCredential,
+} from '@/lib/credentials/orchestration'
+import { getWorkspaceCredential } from '@/lib/credentials/queries'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { toV2CredentialRow, v2CredentialOrchestrationError } from '@/app/api/v2/credentials/utils'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2CredentialDetailAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+interface RouteContext {
+ params: Promise<{ id: string }>
+}
+
+/** GET /api/v2/credentials/[id] — Fetch a single credential. Secrets are never returned. */
+export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'credential-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2GetCredentialContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const credential = await getWorkspaceCredential({ workspaceId, credentialId: id })
+ if (!credential) return v2Error('NOT_FOUND', 'Credential not found')
+
+ /**
+ * Workspace access is not credential access: seeing a credential requires a
+ * membership row (or workspace admin over a shared type). A caller who has
+ * neither gets 404 rather than 403 so credential existence never leaks to
+ * someone who cannot use it.
+ */
+ const actor = await getCredentialActorContext(id, userId)
+ if (!actor.member && !actor.isAdmin) return v2Error('NOT_FOUND', 'Credential not found')
+
+ return v2Data(
+ { credential: toV2CredentialRow(credential, actor.isAdmin ? 'admin' : 'member') },
+ { rateLimit }
+ )
+ } catch (error) {
+ logger.error(`[${requestId}] Error fetching credential`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/** PATCH /api/v2/credentials/[id] — Rename, re-describe, or rotate a credential's secret. */
+export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'credential-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2UpdateCredentialContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+ const { workspaceId, ...changes } = parsed.data.body
+
+ /**
+ * Credential mutations are gated per credential, not per workspace:
+ * `performUpdateCredential` requires credential admin, and the internal
+ * surface applies no workspace-level bar at all. Requiring workspace `write`
+ * here would lock out a credential admin who only holds `read`.
+ */
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
+ if (access) return v2WorkspaceAccessError(access)
+
+ // Tenant-scope the id before the orchestration re-derives access from the
+ // credential's own workspace.
+ const existing = await getWorkspaceCredential({ workspaceId, credentialId: id })
+ if (!existing) return v2Error('NOT_FOUND', 'Credential not found')
+
+ const actor = await getCredentialActorContext(id, userId)
+ if (!actor.member && !actor.isAdmin) return v2Error('NOT_FOUND', 'Credential not found')
+
+ /**
+ * An env credential's display name IS its `envKey` — the lib only applies
+ * `displayName` to `oauth` and `service_account`, so accepting it here would
+ * either drop the rename silently (when sent alongside `description`) or
+ * fail with an unrelated environment-editor message (when sent alone).
+ */
+ if (
+ changes.displayName !== undefined &&
+ (existing.type === 'env_workspace' || existing.type === 'env_personal')
+ ) {
+ return v2Error(
+ 'BAD_REQUEST',
+ 'displayName cannot be set on an environment credential — its name is its envKey. Delete it and create one under the new key.'
+ )
+ }
+
+ const result = await performUpdateCredential({ ...changes, credentialId: id, userId, request })
+
+ if (!result.success) {
+ return v2CredentialOrchestrationError(
+ result.errorCode,
+ result.error ?? 'Failed to update credential',
+ { providerUnavailable: isProviderOutageCode(result.providerErrorCode) }
+ )
+ }
+
+ const updated = await getWorkspaceCredential({ workspaceId, credentialId: id })
+ if (!updated) return v2Error('NOT_FOUND', 'Credential not found')
+
+ return v2Data({ credential: toV2CredentialRow(updated, 'admin') }, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Error updating credential`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/** DELETE /api/v2/credentials/[id] — Delete a credential and revoke what it backed. */
+export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'credential-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2DeleteCredentialContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+
+ // Gated per credential by `performDeleteCredential`, same as PATCH above.
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const existing = await getWorkspaceCredential({ workspaceId, credentialId: id })
+ if (!existing) return v2Error('NOT_FOUND', 'Credential not found')
+
+ /**
+ * A credential the caller cannot see answers 404, matching GET, so a
+ * workspace member cannot tell an inaccessible credential from a missing one
+ * and enumerate ids. A credential they *can* see but cannot administer still
+ * gets the orchestration's 403 — that distinction is not a leak, since GET
+ * already shows them the credential.
+ */
+ const actor = await getCredentialActorContext(id, userId)
+ if (!actor.member && !actor.isAdmin) return v2Error('NOT_FOUND', 'Credential not found')
+
+ const result = await performDeleteCredential({ credentialId: id, userId, request })
+ if (!result.success) {
+ return v2CredentialOrchestrationError(
+ result.errorCode,
+ result.error ?? 'Failed to delete credential'
+ )
+ }
+
+ return v2Data({ id, deleted: true as const }, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Error deleting credential`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts
new file mode 100644
index 00000000000..3833eb52c84
--- /dev/null
+++ b/apps/sim/app/api/v2/credentials/route.test.ts
@@ -0,0 +1,384 @@
+/**
+ * @vitest-environment node
+ *
+ * Public v2 credentials list/create: gate ordering, the write-only treatment of
+ * secret material, and the exclusion of `oauth` from the creatable types.
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockResolveWorkspaceAccess,
+ mockCheckWorkspaceAccess,
+ mockListVisibleWorkspaceCredentials,
+ mockPerformCreateCredential,
+ mockGetCredentialActorContext,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceAccess: vi.fn(),
+ mockCheckWorkspaceAccess: vi.fn(),
+ mockListVisibleWorkspaceCredentials: vi.fn(),
+ mockPerformCreateCredential: vi.fn(),
+ mockGetCredentialActorContext: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceAccess: mockResolveWorkspaceAccess,
+}))
+
+vi.mock('@/lib/workspaces/permissions/utils', () => ({
+ checkWorkspaceAccess: mockCheckWorkspaceAccess,
+}))
+
+vi.mock('@/lib/credentials/queries', () => ({
+ listVisibleWorkspaceCredentials: mockListVisibleWorkspaceCredentials,
+}))
+
+vi.mock('@/lib/credentials/orchestration', () => ({
+ performCreateCredential: mockPerformCreateCredential,
+}))
+
+vi.mock('@/lib/credentials/access', () => ({
+ getCredentialActorContext: mockGetCredentialActorContext,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+import { GET, POST } from '@/app/api/v2/credentials/route'
+
+const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555'
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+}
+
+const RATE_LIMIT_DENIED = {
+ allowed: false,
+ limit: 100,
+ remaining: 0,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+ retryAfterMs: 1000,
+}
+
+function buildVisible(overrides: Record = {}) {
+ return {
+ id: 'cred_abc123',
+ workspaceId: WORKSPACE_ID,
+ type: 'service_account' as const,
+ displayName: 'Zoom account acct_123',
+ description: null,
+ providerId: 'zoom-service-account',
+ accountId: null,
+ envKey: null,
+ envOwnerUserId: null,
+ createdBy: 'user-1',
+ createdAt: new Date('2024-01-01T00:00:00Z'),
+ updatedAt: new Date('2024-01-02T00:00:00Z'),
+ hasServiceAccountKey: true,
+ role: 'admin' as const,
+ ...overrides,
+ }
+}
+
+function buildRow(overrides: Record = {}) {
+ return {
+ id: 'cred_abc123',
+ workspaceId: WORKSPACE_ID,
+ type: 'service_account',
+ displayName: 'Zoom account acct_123',
+ description: null,
+ providerId: 'zoom-service-account',
+ accountId: null,
+ envKey: null,
+ envOwnerUserId: null,
+ encryptedServiceAccountKey: 'encrypted-blob',
+ createdBy: 'user-1',
+ createdAt: new Date('2024-01-01T00:00:00Z'),
+ updatedAt: new Date('2024-01-02T00:00:00Z'),
+ ...overrides,
+ }
+}
+
+const callList = (query: string) =>
+ GET(new NextRequest(`http://localhost:3000/api/v2/credentials?${query}`))
+
+function callCreate(body: unknown) {
+ return POST(
+ new NextRequest('http://localhost:3000/api/v2/credentials', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ )
+}
+
+const VALID_BODY = {
+ workspaceId: WORKSPACE_ID,
+ type: 'env_workspace',
+ envKey: 'STRIPE_API_KEY',
+}
+
+describe('GET /api/v2/credentials', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: true })
+ mockListVisibleWorkspaceCredentials.mockResolvedValue([buildVisible()])
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callList(`workspaceId=${WORKSPACE_ID}`)
+
+ expect(res.status).toBe(404)
+ expect(mockListVisibleWorkspaceCredentials).not.toHaveBeenCalled()
+ })
+
+ it('400s when workspaceId is missing', async () => {
+ const res = await callList('')
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(mockListVisibleWorkspaceCredentials).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue({
+ status: 403,
+ code: 'FORBIDDEN',
+ message: 'Access denied',
+ })
+ const res = await callList(`workspaceId=${WORKSPACE_ID}`)
+ expect(res.status).toBe(403)
+ expect(mockListVisibleWorkspaceCredentials).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callList(`workspaceId=${WORKSPACE_ID}`)
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('returns the public credential shape with no secret material', async () => {
+ const res = await callList(`workspaceId=${WORKSPACE_ID}`)
+ const body = await res.json()
+
+ expect(res.status).toBe(200)
+ expect(body.nextCursor).toBeNull()
+ expect(body.data).toEqual([
+ {
+ id: 'cred_abc123',
+ type: 'service_account',
+ displayName: 'Zoom account acct_123',
+ description: null,
+ providerId: 'zoom-service-account',
+ accountId: null,
+ envKey: null,
+ hasServiceAccountKey: true,
+ role: 'admin',
+ createdAt: '2024-01-01T00:00:00.000Z',
+ updatedAt: '2024-01-02T00:00:00.000Z',
+ },
+ ])
+ expect(mockListVisibleWorkspaceCredentials).toHaveBeenCalledWith(
+ expect.objectContaining({ workspaceId: WORKSPACE_ID, userId: 'user-1' })
+ )
+ })
+
+ it('passes the type and providerId filters through', async () => {
+ await callList(`workspaceId=${WORKSPACE_ID}&type=oauth&providerId=slack`)
+ expect(mockListVisibleWorkspaceCredentials).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'oauth', providerId: 'slack' })
+ )
+ })
+ it('400s on a sort field outside the enum instead of letting it reach the query', async () => {
+ const res = await callList(`workspaceId=${WORKSPACE_ID}&sortBy=name);--`)
+
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ })
+
+ it('400s on a sort direction outside the enum', async () => {
+ const res = await callList(`workspaceId=${WORKSPACE_ID}&sortOrder=sideways`)
+
+ expect(res.status).toBe(400)
+ })
+
+ it('400s on an empty search rather than treating it as unsearched', async () => {
+ const res = await callList(`workspaceId=${WORKSPACE_ID}&search=`)
+
+ expect(res.status).toBe(400)
+ })
+
+ it('forwards search and sort into the query and still terminates pagination', async () => {
+ const res = await callList(
+ `workspaceId=${WORKSPACE_ID}&search=report&sortBy=displayName&sortOrder=asc`
+ )
+
+ expect(res.status).toBe(200)
+ expect((await res.json()).nextCursor).toBeNull()
+ })
+})
+
+describe('POST /api/v2/credentials', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockPerformCreateCredential.mockResolvedValue({
+ success: true,
+ credential: buildRow(),
+ created: true,
+ })
+ mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false })
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callCreate(VALID_BODY)
+
+ expect(res.status).toBe(404)
+ expect(mockPerformCreateCredential).not.toHaveBeenCalled()
+ })
+
+ it('400s when envKey is missing for an env credential', async () => {
+ const res = await callCreate({ workspaceId: WORKSPACE_ID, type: 'env_workspace' })
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(mockPerformCreateCredential).not.toHaveBeenCalled()
+ })
+
+ it('400s when envKey is not a valid environment variable name', async () => {
+ const res = await callCreate({ ...VALID_BODY, envKey: 'not-a-valid-name' })
+ expect(res.status).toBe(400)
+ expect(mockPerformCreateCredential).not.toHaveBeenCalled()
+ })
+
+ it('400s on an oauth create, which requires the interactive connect flow', async () => {
+ const res = await callCreate({
+ workspaceId: WORKSPACE_ID,
+ type: 'oauth',
+ providerId: 'slack',
+ accountId: 'acct_1',
+ displayName: 'Slack',
+ })
+ expect(res.status).toBe(400)
+ expect(mockPerformCreateCredential).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue({
+ status: 403,
+ code: 'FORBIDDEN',
+ message: 'Access denied',
+ })
+ const res = await callCreate(VALID_BODY)
+ expect(res.status).toBe(403)
+ expect(mockPerformCreateCredential).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callCreate(VALID_BODY)
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('maps a provider outage to 503 rather than a bad request', async () => {
+ mockPerformCreateCredential.mockResolvedValue({
+ success: false,
+ error: 'provider_unavailable',
+ errorCode: 'validation',
+ providerErrorCode: 'provider_unavailable',
+ providerUnavailable: true,
+ })
+ const res = await callCreate(VALID_BODY)
+ expect(res.status).toBe(503)
+ expect((await res.json()).error.code).toBe('SERVICE_UNAVAILABLE')
+ })
+
+ it('reports the real role when an idempotent create matches a credential the caller only belongs to', async () => {
+ mockPerformCreateCredential.mockResolvedValue({
+ success: true,
+ credential: buildRow(),
+ created: false,
+ })
+ mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'member' }, isAdmin: false })
+
+ const res = await callCreate(VALID_BODY)
+ const body = await res.json()
+
+ expect(res.status).toBe(201)
+ expect(body.data.credential.role).toBe('member')
+ })
+
+ it('reports admin for a fresh insert without a second access lookup', async () => {
+ const res = await callCreate(VALID_BODY)
+
+ expect((await res.json()).data.credential.role).toBe('admin')
+ expect(mockGetCredentialActorContext).not.toHaveBeenCalled()
+ })
+
+ it('creates the credential and never echoes the submitted secret', async () => {
+ const res = await callCreate({
+ workspaceId: WORKSPACE_ID,
+ type: 'service_account',
+ providerId: 'zoom-service-account',
+ clientId: 'zoom-client-id',
+ clientSecret: 'super-secret-value',
+ orgId: 'acct_123',
+ })
+ const body = await res.json()
+
+ expect(res.status).toBe(201)
+ expect(body.data.credential).toMatchObject({
+ id: 'cred_abc123',
+ hasServiceAccountKey: true,
+ role: 'admin',
+ })
+ expect(JSON.stringify(body)).not.toContain('super-secret-value')
+ expect(JSON.stringify(body)).not.toContain('encrypted-blob')
+ expect(mockPerformCreateCredential).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workspaceId: WORKSPACE_ID,
+ userId: 'user-1',
+ type: 'service_account',
+ clientSecret: 'super-secret-value',
+ })
+ )
+ })
+
+ it('accepts and forwards an optional service-account data center', async () => {
+ const res = await callCreate({
+ workspaceId: WORKSPACE_ID,
+ type: 'service_account',
+ providerId: 'zoho-desk-service-account',
+ clientId: 'zoho-client-id',
+ clientSecret: 'zoho-client-secret',
+ orgId: '600123456',
+ dataCenter: 'eu',
+ })
+
+ expect(res.status).toBe(201)
+ expect(mockPerformCreateCredential).toHaveBeenCalledWith(
+ expect.objectContaining({ dataCenter: 'eu' })
+ )
+ expect(JSON.stringify(await res.json())).not.toContain('dataCenter')
+ })
+})
diff --git a/apps/sim/app/api/v2/credentials/route.ts b/apps/sim/app/api/v2/credentials/route.ts
new file mode 100644
index 00000000000..2b710b275bf
--- /dev/null
+++ b/apps/sim/app/api/v2/credentials/route.ts
@@ -0,0 +1,150 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import {
+ v2CreateCredentialContract,
+ v2ListCredentialsContract,
+} from '@/lib/api/contracts/v2/credentials'
+import { parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getCredentialActorContext } from '@/lib/credentials/access'
+import { performCreateCredential } from '@/lib/credentials/orchestration'
+import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries'
+import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import {
+ toV2Credential,
+ toV2CredentialRow,
+ v2CredentialOrchestrationError,
+} from '@/app/api/v2/credentials/utils'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CursorList,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2CredentialsAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+/** GET /api/v2/credentials — List the credentials the caller can see in a workspace. */
+export const GET = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'credentials')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2ListCredentialsContract,
+ request,
+ {},
+ { validationErrorResponse: v2ValidationError }
+ )
+ if (!parsed.success) return parsed.response
+
+ const { workspaceId, type, providerId, search, sortBy, sortOrder } = parsed.data.query
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
+ if (access) return v2WorkspaceAccessError(access)
+
+ /**
+ * Credential visibility is per credential, not per workspace: membership
+ * rows and shared-type admin access decide what this caller sees, so the
+ * workspace permission is re-read here for the `canAdmin` bit.
+ */
+ const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId)
+ const credentials = await listVisibleWorkspaceCredentials({
+ workspaceId,
+ userId,
+ workspaceAccess,
+ type,
+ providerId,
+ search,
+ sortBy,
+ sortOrder,
+ })
+
+ // The per-workspace credential set is small and bounded → a single full page.
+ return v2CursorList(credentials.map(toV2Credential), null, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Error listing credentials`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/** POST /api/v2/credentials — Create a workspace credential. */
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'credentials')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2CreateCredentialContract,
+ request,
+ {},
+ { validationErrorResponse: v2ValidationError }
+ )
+ if (!parsed.success) return parsed.response
+
+ const { workspaceId } = parsed.data.body
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const result = await performCreateCredential({ ...parsed.data.body, userId, request })
+
+ if (!result.success || !result.credential) {
+ return v2CredentialOrchestrationError(
+ result.errorCode,
+ result.error ?? 'Failed to create credential',
+ { providerUnavailable: result.providerUnavailable }
+ )
+ }
+
+ /**
+ * A fresh insert makes the creator an admin, but an idempotent match against
+ * an existing source does not — the orchestration admits a caller who is
+ * only a *member* of that credential. Resolve the real role rather than
+ * assuming the create case, or the response would advertise administrative
+ * actions the caller cannot perform.
+ */
+ const actor = result.created
+ ? { isAdmin: true }
+ : await getCredentialActorContext(result.credential.id, userId)
+ const credential = toV2CredentialRow(result.credential, actor.isAdmin ? 'admin' : 'member')
+
+ /**
+ * Always 201, including when an existing credential already occupied this
+ * source. Create is idempotent on the source tuple, and the caller's
+ * post-condition — "a credential with this source exists, here it is" — is
+ * the same either way.
+ */
+ return v2Data({ credential }, { rateLimit, status: 201 })
+ } catch (error) {
+ logger.error(`[${requestId}] Error creating credential`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/credentials/utils.ts b/apps/sim/app/api/v2/credentials/utils.ts
new file mode 100644
index 00000000000..b70903da663
--- /dev/null
+++ b/apps/sim/app/api/v2/credentials/utils.ts
@@ -0,0 +1,75 @@
+import type { NextResponse } from 'next/server'
+import type { V2Credential } from '@/lib/api/contracts/v2/credentials'
+import type { CredentialOrchestrationErrorCode } from '@/lib/credentials/orchestration'
+import type { CredentialRow, VisibleWorkspaceCredential } from '@/lib/credentials/queries'
+import { v2Error } from '@/app/api/v2/lib/response'
+
+/**
+ * Shared serialization + error mapping for the v2 credentials surface.
+ *
+ * Both projections are written field by field on purpose: a credential row
+ * carries `encryptedServiceAccountKey`, and spreading the row would put it one
+ * forgotten `omit` away from the wire.
+ */
+
+export function toV2Credential(row: VisibleWorkspaceCredential): V2Credential {
+ return {
+ id: row.id,
+ type: row.type,
+ displayName: row.displayName,
+ description: row.description,
+ providerId: row.providerId,
+ accountId: row.accountId,
+ envKey: row.envKey,
+ hasServiceAccountKey: row.hasServiceAccountKey,
+ role: row.role,
+ createdAt: row.createdAt.toISOString(),
+ updatedAt: row.updatedAt.toISOString(),
+ }
+}
+
+/** Projection for a raw credential row, whose caller-role is resolved separately. */
+export function toV2CredentialRow(row: CredentialRow, role: V2Credential['role']): V2Credential {
+ return {
+ id: row.id,
+ type: row.type,
+ displayName: row.displayName,
+ description: row.description,
+ providerId: row.providerId,
+ accountId: row.accountId,
+ envKey: row.envKey,
+ hasServiceAccountKey: Boolean(row.encryptedServiceAccountKey),
+ role,
+ createdAt: row.createdAt.toISOString(),
+ updatedAt: row.updatedAt.toISOString(),
+ }
+}
+
+/**
+ * Renders a credential orchestration failure in the v2 error envelope.
+ *
+ * `forbidden` from the orchestration means "not an admin of this credential",
+ * which is a resource-level denial rather than a workspace one; it stays a 403
+ * because the caller already proved workspace access to reach it.
+ */
+export function v2CredentialOrchestrationError(
+ errorCode: CredentialOrchestrationErrorCode | undefined,
+ message: string,
+ options: { providerUnavailable?: boolean } = {}
+): NextResponse {
+ if (options.providerUnavailable) {
+ return v2Error('SERVICE_UNAVAILABLE', 'The credential provider is unavailable. Try again.')
+ }
+ switch (errorCode) {
+ case 'validation':
+ return v2Error('BAD_REQUEST', message)
+ case 'forbidden':
+ return v2Error('FORBIDDEN', message)
+ case 'not_found':
+ return v2Error('NOT_FOUND', 'Credential not found')
+ case 'conflict':
+ return v2Error('CONFLICT', message)
+ default:
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+}
diff --git a/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts b/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts
new file mode 100644
index 00000000000..5619a64b1cd
--- /dev/null
+++ b/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts
@@ -0,0 +1,308 @@
+/**
+ * @vitest-environment node
+ *
+ * Public v2 custom tool detail: the per-id get/update/delete the internal
+ * surface never had, and the rename guard that keeps a duplicate title from
+ * reaching the unique index.
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockResolveWorkspaceAccess,
+ mockGetWorkspaceCustomTool,
+ mockGetWorkspaceCustomToolByTitle,
+ mockDeleteWorkspaceCustomTool,
+ mockUpdateWorkspaceCustomTool,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceAccess: vi.fn(),
+ mockGetWorkspaceCustomTool: vi.fn(),
+ mockGetWorkspaceCustomToolByTitle: vi.fn(),
+ mockDeleteWorkspaceCustomTool: vi.fn(),
+ mockUpdateWorkspaceCustomTool: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceAccess: mockResolveWorkspaceAccess,
+}))
+
+vi.mock('@/lib/workflows/custom-tools/operations', () => ({
+ getWorkspaceCustomTool: mockGetWorkspaceCustomTool,
+ getWorkspaceCustomToolByTitle: mockGetWorkspaceCustomToolByTitle,
+ deleteWorkspaceCustomTool: mockDeleteWorkspaceCustomTool,
+ updateWorkspaceCustomTool: mockUpdateWorkspaceCustomTool,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+import { DELETE, GET, PATCH } from '@/app/api/v2/custom-tools/[id]/route'
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+}
+
+const RATE_LIMIT_DENIED = {
+ allowed: false,
+ limit: 100,
+ remaining: 0,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+ retryAfterMs: 1000,
+}
+
+const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' }
+
+const TOOL_SCHEMA = {
+ type: 'function',
+ function: {
+ name: 'lookup_order',
+ parameters: { type: 'object', properties: { orderId: { type: 'string' } } },
+ },
+}
+
+function buildTool(overrides: Record = {}) {
+ return {
+ id: 'tool_abc123',
+ workspaceId: 'workspace-1',
+ userId: 'user-1',
+ title: 'lookup_order',
+ schema: TOOL_SCHEMA,
+ code: 'return { ok: true }',
+ createdAt: new Date('2024-01-01T00:00:00Z'),
+ updatedAt: new Date('2024-01-02T00:00:00Z'),
+ ...overrides,
+ }
+}
+
+const routeContext = () => ({ params: Promise.resolve({ id: 'tool_abc123' }) })
+const url = (query = 'workspaceId=workspace-1') =>
+ `http://localhost:3000/api/v2/custom-tools/tool_abc123?${query}`
+
+const callGet = (query?: string) => GET(new NextRequest(url(query)), routeContext())
+const callDelete = (query?: string) =>
+ DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext())
+
+function callPatch(body: unknown) {
+ return PATCH(
+ new NextRequest('http://localhost:3000/api/v2/custom-tools/tool_abc123', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ }),
+ routeContext()
+ )
+}
+
+describe('GET /api/v2/custom-tools/[id]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockGetWorkspaceCustomTool.mockResolvedValue(buildTool())
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callGet()
+
+ expect(res.status).toBe(404)
+ expect(mockGetWorkspaceCustomTool).not.toHaveBeenCalled()
+ })
+
+ it('400s when workspaceId is missing', async () => {
+ const res = await callGet('')
+ expect(res.status).toBe(400)
+ expect(mockGetWorkspaceCustomTool).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED)
+ const res = await callGet()
+ expect(res.status).toBe(403)
+ expect(mockGetWorkspaceCustomTool).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callGet()
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('404s when the tool is not in the workspace', async () => {
+ mockGetWorkspaceCustomTool.mockResolvedValue(null)
+ const res = await callGet()
+ expect(res.status).toBe(404)
+ expect((await res.json()).error.code).toBe('NOT_FOUND')
+ })
+
+ it('returns the public tool shape without internal scoping columns', async () => {
+ const res = await callGet()
+ const body = await res.json()
+
+ expect(res.status).toBe(200)
+ expect(body.data.customTool).toEqual({
+ id: 'tool_abc123',
+ title: 'lookup_order',
+ schema: TOOL_SCHEMA,
+ code: 'return { ok: true }',
+ createdAt: '2024-01-01T00:00:00.000Z',
+ updatedAt: '2024-01-02T00:00:00.000Z',
+ })
+ expect(mockGetWorkspaceCustomTool).toHaveBeenCalledWith({
+ workspaceId: 'workspace-1',
+ toolId: 'tool_abc123',
+ })
+ })
+})
+
+describe('PATCH /api/v2/custom-tools/[id]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockGetWorkspaceCustomTool.mockResolvedValue(buildTool())
+ mockGetWorkspaceCustomToolByTitle.mockResolvedValue(null)
+ mockUpdateWorkspaceCustomTool.mockResolvedValue(buildTool())
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' })
+
+ expect(res.status).toBe(404)
+ expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled()
+ })
+
+ it('400s when no field to change is supplied', async () => {
+ const res = await callPatch({ workspaceId: 'workspace-1' })
+ expect(res.status).toBe(400)
+ expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED)
+ const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' })
+ expect(res.status).toBe(403)
+ expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' })
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('404s when the tool is not in the workspace', async () => {
+ mockGetWorkspaceCustomTool.mockResolvedValue(null)
+ const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' })
+ expect(res.status).toBe(404)
+ expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled()
+ })
+
+ it('409s when renaming onto an existing title', async () => {
+ mockGetWorkspaceCustomToolByTitle.mockResolvedValue(buildTool({ id: 'tool_other' }))
+
+ const res = await callPatch({ workspaceId: 'workspace-1', title: 'taken' })
+
+ expect(res.status).toBe(409)
+ expect((await res.json()).error.code).toBe('CONFLICT')
+ expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled()
+ })
+
+ it('merges the partial body against the stored tool', async () => {
+ const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 2' })
+
+ expect(res.status).toBe(200)
+ expect(mockUpdateWorkspaceCustomTool).toHaveBeenCalledWith({
+ workspaceId: 'workspace-1',
+ toolId: 'tool_abc123',
+ title: 'lookup_order',
+ schema: TOOL_SCHEMA,
+ code: 'return 2',
+ })
+ })
+
+ it('404s rather than orphaning a tool deleted between the read and the write', async () => {
+ mockUpdateWorkspaceCustomTool.mockResolvedValue(null)
+
+ const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 2' })
+
+ expect(res.status).toBe(404)
+ expect((await res.json()).error.code).toBe('NOT_FOUND')
+ })
+})
+
+describe('DELETE /api/v2/custom-tools/[id]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockGetWorkspaceCustomTool.mockResolvedValue(buildTool())
+ mockDeleteWorkspaceCustomTool.mockResolvedValue(true)
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callDelete()
+
+ expect(res.status).toBe(404)
+ expect(mockDeleteWorkspaceCustomTool).not.toHaveBeenCalled()
+ })
+
+ it('400s when workspaceId is missing', async () => {
+ const res = await callDelete('')
+ expect(res.status).toBe(400)
+ expect(mockDeleteWorkspaceCustomTool).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED)
+ const res = await callDelete()
+ expect(res.status).toBe(403)
+ expect(mockDeleteWorkspaceCustomTool).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callDelete()
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('404s when the tool is not in the workspace', async () => {
+ mockGetWorkspaceCustomTool.mockResolvedValue(null)
+ const res = await callDelete()
+ expect(res.status).toBe(404)
+ expect(mockDeleteWorkspaceCustomTool).not.toHaveBeenCalled()
+ })
+
+ it('deletes the tool and acknowledges the id', async () => {
+ const res = await callDelete()
+ expect(res.status).toBe(200)
+ expect(await res.json()).toEqual({ data: { id: 'tool_abc123', deleted: true } })
+ expect(mockDeleteWorkspaceCustomTool).toHaveBeenCalledWith({
+ workspaceId: 'workspace-1',
+ toolId: 'tool_abc123',
+ })
+ })
+})
diff --git a/apps/sim/app/api/v2/custom-tools/[id]/route.ts b/apps/sim/app/api/v2/custom-tools/[id]/route.ts
new file mode 100644
index 00000000000..e793c31c3ea
--- /dev/null
+++ b/apps/sim/app/api/v2/custom-tools/[id]/route.ts
@@ -0,0 +1,197 @@
+import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import {
+ v2DeleteCustomToolContract,
+ v2GetCustomToolContract,
+ v2UpdateCustomToolContract,
+} from '@/lib/api/contracts/v2/custom-tools'
+import { parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ deleteWorkspaceCustomTool,
+ getWorkspaceCustomTool,
+ getWorkspaceCustomToolByTitle,
+ updateWorkspaceCustomTool,
+} from '@/lib/workflows/custom-tools/operations'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { toV2CustomTool, v2CustomToolWriteError } from '@/app/api/v2/custom-tools/utils'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2CustomToolDetailAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+interface RouteContext {
+ params: Promise<{ id: string }>
+}
+
+/** GET /api/v2/custom-tools/[id] — Fetch a single custom tool. */
+export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'custom-tool-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2GetCustomToolContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const tool = await getWorkspaceCustomTool({ workspaceId, toolId: id })
+ if (!tool) return v2Error('NOT_FOUND', 'Custom tool not found')
+
+ return v2Data({ customTool: toV2CustomTool(tool) }, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Error fetching custom tool`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/** PATCH /api/v2/custom-tools/[id] — Update a custom tool. Omitted fields keep their values. */
+export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'custom-tool-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2UpdateCustomToolContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+ const { workspaceId, title, schema, code } = parsed.data.body
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const current = await getWorkspaceCustomTool({ workspaceId, toolId: id })
+ if (!current) return v2Error('NOT_FOUND', 'Custom tool not found')
+
+ /**
+ * `upsertCustomTools` replaces title/schema/code wholesale and checks for a
+ * duplicate title only when inserting, so a rename onto an existing title
+ * would hit the `custom_tools_workspace_title_unique` index as a 500. Merge
+ * the partial body against the stored row and check the rename here.
+ */
+ if (title !== undefined && title !== current.title) {
+ if (await getWorkspaceCustomToolByTitle({ workspaceId, title })) {
+ return v2Error(
+ 'CONFLICT',
+ `A custom tool titled "${title}" already exists in this workspace`
+ )
+ }
+ }
+
+ const updated = await updateWorkspaceCustomTool({
+ workspaceId,
+ toolId: id,
+ title: title ?? current.title,
+ schema: schema ?? current.schema,
+ code: code ?? current.code,
+ })
+ if (!updated) return v2Error('NOT_FOUND', 'Custom tool not found')
+
+ recordAudit({
+ workspaceId,
+ actorId: userId,
+ action: AuditAction.CUSTOM_TOOL_UPDATED,
+ resourceType: AuditResourceType.CUSTOM_TOOL,
+ resourceId: updated.id,
+ resourceName: updated.title,
+ description: `Updated custom tool "${updated.title}" via API`,
+ request,
+ })
+
+ return v2Data({ customTool: toV2CustomTool(updated) }, { rateLimit })
+ } catch (error) {
+ const writeError = v2CustomToolWriteError(error)
+ if (writeError) return writeError
+
+ logger.error(`[${requestId}] Error updating custom tool`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/** DELETE /api/v2/custom-tools/[id] — Delete a custom tool. */
+export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'custom-tool-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2DeleteCustomToolContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const tool = await getWorkspaceCustomTool({ workspaceId, toolId: id })
+ if (!tool) return v2Error('NOT_FOUND', 'Custom tool not found')
+
+ const deleted = await deleteWorkspaceCustomTool({ workspaceId, toolId: id })
+ if (!deleted) return v2Error('NOT_FOUND', 'Custom tool not found')
+
+ recordAudit({
+ workspaceId,
+ actorId: userId,
+ action: AuditAction.CUSTOM_TOOL_DELETED,
+ resourceType: AuditResourceType.CUSTOM_TOOL,
+ resourceId: id,
+ resourceName: tool.title,
+ description: `Deleted custom tool "${tool.title}" via API`,
+ request,
+ })
+
+ return v2Data({ id, deleted: true as const }, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Error deleting custom tool`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/custom-tools/route.test.ts b/apps/sim/app/api/v2/custom-tools/route.test.ts
new file mode 100644
index 00000000000..7ca46e81b0c
--- /dev/null
+++ b/apps/sim/app/api/v2/custom-tools/route.test.ts
@@ -0,0 +1,302 @@
+/**
+ * @vitest-environment node
+ *
+ * Public v2 custom tools list/create: gate ordering, contract validation, and
+ * the workspace-scoped single-resource create that replaced the bulk upsert.
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockResolveWorkspaceAccess,
+ mockListWorkspaceCustomTools,
+ mockGetWorkspaceCustomToolByTitle,
+ mockUpsertCustomTools,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceAccess: vi.fn(),
+ mockListWorkspaceCustomTools: vi.fn(),
+ mockGetWorkspaceCustomToolByTitle: vi.fn(),
+ mockUpsertCustomTools: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceAccess: mockResolveWorkspaceAccess,
+}))
+
+vi.mock('@/lib/workflows/custom-tools/operations', () => ({
+ listWorkspaceCustomTools: mockListWorkspaceCustomTools,
+ getWorkspaceCustomToolByTitle: mockGetWorkspaceCustomToolByTitle,
+ upsertCustomTools: mockUpsertCustomTools,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+import { GET, POST } from '@/app/api/v2/custom-tools/route'
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+}
+
+const RATE_LIMIT_DENIED = {
+ allowed: false,
+ limit: 100,
+ remaining: 0,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+ retryAfterMs: 1000,
+}
+
+const TOOL_SCHEMA = {
+ type: 'function',
+ function: {
+ name: 'lookup_order',
+ description: 'Look up an order by id',
+ parameters: {
+ type: 'object',
+ properties: { orderId: { type: 'string' } },
+ required: ['orderId'],
+ },
+ },
+}
+
+function buildTool(overrides: Record = {}) {
+ return {
+ id: 'tool_abc123',
+ workspaceId: 'workspace-1',
+ userId: 'user-1',
+ title: 'lookup_order',
+ schema: TOOL_SCHEMA,
+ code: 'return { ok: true }',
+ createdAt: new Date('2024-01-01T00:00:00Z'),
+ updatedAt: new Date('2024-01-02T00:00:00Z'),
+ ...overrides,
+ }
+}
+
+/** What the route forwards for a bare `?workspaceId=` list. */
+const DEFAULT_LIST_ARGS = {
+ search: undefined,
+ sortBy: 'createdAt',
+ sortOrder: 'desc',
+}
+
+const callList = (query: string) =>
+ GET(new NextRequest(`http://localhost:3000/api/v2/custom-tools?${query}`))
+
+function callCreate(body: unknown) {
+ return POST(
+ new NextRequest('http://localhost:3000/api/v2/custom-tools', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ )
+}
+
+const VALID_BODY = {
+ workspaceId: 'workspace-1',
+ title: 'lookup_order',
+ schema: TOOL_SCHEMA,
+ code: 'return { ok: true }',
+}
+
+describe('GET /api/v2/custom-tools', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockListWorkspaceCustomTools.mockResolvedValue([buildTool()])
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callList('workspaceId=workspace-1')
+
+ expect(res.status).toBe(404)
+ expect(mockListWorkspaceCustomTools).not.toHaveBeenCalled()
+ })
+
+ it('400s when workspaceId is missing', async () => {
+ const res = await callList('')
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(mockListWorkspaceCustomTools).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue({
+ status: 403,
+ code: 'FORBIDDEN',
+ message: 'Access denied',
+ })
+ const res = await callList('workspaceId=workspace-1')
+ expect(res.status).toBe(403)
+ expect(mockListWorkspaceCustomTools).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callList('workspaceId=workspace-1')
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('returns the public tool shape in the cursor envelope, workspace-scoped', async () => {
+ const res = await callList('workspaceId=workspace-1')
+ const body = await res.json()
+
+ expect(res.status).toBe(200)
+ expect(body.nextCursor).toBeNull()
+ expect(body.data).toEqual([
+ {
+ id: 'tool_abc123',
+ title: 'lookup_order',
+ schema: TOOL_SCHEMA,
+ code: 'return { ok: true }',
+ createdAt: '2024-01-01T00:00:00.000Z',
+ updatedAt: '2024-01-02T00:00:00.000Z',
+ },
+ ])
+ expect(mockListWorkspaceCustomTools).toHaveBeenCalledWith({
+ workspaceId: 'workspace-1',
+ ...DEFAULT_LIST_ARGS,
+ })
+ })
+ it('400s on a sort field outside the enum instead of letting it reach the query', async () => {
+ const res = await callList(`workspaceId=workspace-1&sortBy=name);--`)
+
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ })
+
+ it('400s on a sort direction outside the enum', async () => {
+ const res = await callList(`workspaceId=workspace-1&sortOrder=sideways`)
+
+ expect(res.status).toBe(400)
+ })
+
+ it('400s on an empty search rather than treating it as unsearched', async () => {
+ const res = await callList(`workspaceId=workspace-1&search=`)
+
+ expect(res.status).toBe(400)
+ })
+
+ it('forwards search and sort into the query and still terminates pagination', async () => {
+ const res = await callList(`workspaceId=workspace-1&search=report&sortBy=title&sortOrder=asc`)
+
+ expect(res.status).toBe(200)
+ expect((await res.json()).nextCursor).toBeNull()
+ })
+})
+
+describe('POST /api/v2/custom-tools', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockGetWorkspaceCustomToolByTitle.mockResolvedValue(null)
+ mockUpsertCustomTools.mockResolvedValue([buildTool()])
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callCreate(VALID_BODY)
+
+ expect(res.status).toBe(404)
+ expect(mockUpsertCustomTools).not.toHaveBeenCalled()
+ })
+
+ it('400s when the schema is not an OpenAI function declaration', async () => {
+ const res = await callCreate({ ...VALID_BODY, schema: { type: 'nonsense' } })
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(mockUpsertCustomTools).not.toHaveBeenCalled()
+ })
+
+ it('400s when the body carries an unknown field', async () => {
+ const res = await callCreate({ ...VALID_BODY, bogus: true })
+ expect(res.status).toBe(400)
+ expect(mockUpsertCustomTools).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue({
+ status: 403,
+ code: 'FORBIDDEN',
+ message: 'Access denied',
+ })
+ const res = await callCreate(VALID_BODY)
+ expect(res.status).toBe(403)
+ expect(mockUpsertCustomTools).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callCreate(VALID_BODY)
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('409s on a duplicate title instead of hitting the unique index', async () => {
+ mockGetWorkspaceCustomToolByTitle.mockResolvedValue(buildTool())
+
+ const res = await callCreate(VALID_BODY)
+
+ expect(res.status).toBe(409)
+ expect((await res.json()).error.code).toBe('CONFLICT')
+ expect(mockUpsertCustomTools).not.toHaveBeenCalled()
+ })
+
+ it('409s when a concurrent create loses the title race inside the lib', async () => {
+ mockUpsertCustomTools.mockRejectedValue(
+ new Error('A tool with the title "v2_smoke_tool" already exists in this workspace')
+ )
+
+ const res = await callCreate(VALID_BODY)
+
+ expect(res.status).toBe(409)
+ expect((await res.json()).error.code).toBe('CONFLICT')
+ })
+
+ it('409s when the unique index rejects the loser of a title race', async () => {
+ const pgError = Object.assign(new Error('duplicate key value violates unique constraint'), {
+ code: '23505',
+ })
+ mockUpsertCustomTools.mockRejectedValue(pgError)
+
+ const res = await callCreate(VALID_BODY)
+
+ expect(res.status).toBe(409)
+ expect((await res.json()).error.code).toBe('CONFLICT')
+ })
+
+ it('creates the tool and returns 201 with the single tool', async () => {
+ const res = await callCreate(VALID_BODY)
+ const body = await res.json()
+
+ expect(res.status).toBe(201)
+ expect(body.data.customTool).toMatchObject({ id: 'tool_abc123', title: 'lookup_order' })
+ expect(mockUpsertCustomTools).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workspaceId: 'workspace-1',
+ userId: 'user-1',
+ tools: [{ title: 'lookup_order', schema: TOOL_SCHEMA, code: 'return { ok: true }' }],
+ })
+ )
+ })
+})
diff --git a/apps/sim/app/api/v2/custom-tools/route.ts b/apps/sim/app/api/v2/custom-tools/route.ts
new file mode 100644
index 00000000000..96b678a5078
--- /dev/null
+++ b/apps/sim/app/api/v2/custom-tools/route.ts
@@ -0,0 +1,136 @@
+import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import {
+ v2CreateCustomToolContract,
+ v2ListCustomToolsContract,
+} from '@/lib/api/contracts/v2/custom-tools'
+import { parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ getWorkspaceCustomToolByTitle,
+ listWorkspaceCustomTools,
+ upsertCustomTools,
+} from '@/lib/workflows/custom-tools/operations'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { toV2CustomTool, v2CustomToolWriteError } from '@/app/api/v2/custom-tools/utils'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CursorList,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2CustomToolsAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+/** GET /api/v2/custom-tools — List custom tools in a workspace. */
+export const GET = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'custom-tools')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2ListCustomToolsContract,
+ request,
+ {},
+ { validationErrorResponse: v2ValidationError }
+ )
+ if (!parsed.success) return parsed.response
+
+ const { workspaceId, search, sortBy, sortOrder } = parsed.data.query
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const rows = await listWorkspaceCustomTools({ workspaceId, search, sortBy, sortOrder })
+
+ // The per-workspace tool set is small and bounded → a single full page.
+ return v2CursorList(rows.map(toV2CustomTool), null, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Error listing custom tools`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/** POST /api/v2/custom-tools — Create a custom tool. */
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'custom-tools')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2CreateCustomToolContract,
+ request,
+ {},
+ { validationErrorResponse: v2ValidationError }
+ )
+ if (!parsed.success) return parsed.response
+
+ const { workspaceId, title, schema, code } = parsed.data.body
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+
+ /**
+ * Titles are unique per workspace and tools resolve by title at call time,
+ * so a collision is reported rather than surfacing as a unique-index 500.
+ */
+ if (await getWorkspaceCustomToolByTitle({ workspaceId, title })) {
+ return v2Error('CONFLICT', `A custom tool titled "${title}" already exists in this workspace`)
+ }
+
+ const tools = await upsertCustomTools({
+ tools: [{ title, schema, code }],
+ workspaceId,
+ userId,
+ requestId,
+ })
+ const created = tools.find((tool) => tool.title === title)
+ if (!created) return v2Error('INTERNAL_ERROR', 'Internal server error')
+
+ recordAudit({
+ workspaceId,
+ actorId: userId,
+ action: AuditAction.CUSTOM_TOOL_CREATED,
+ resourceType: AuditResourceType.CUSTOM_TOOL,
+ resourceId: created.id,
+ resourceName: created.title,
+ description: `Created custom tool "${created.title}" via API`,
+ request,
+ })
+
+ return v2Data({ customTool: toV2CustomTool(created) }, { rateLimit, status: 201 })
+ } catch (error) {
+ const writeError = v2CustomToolWriteError(error)
+ if (writeError) return writeError
+
+ logger.error(`[${requestId}] Error creating custom tool`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/custom-tools/utils.ts b/apps/sim/app/api/v2/custom-tools/utils.ts
new file mode 100644
index 00000000000..516101065ad
--- /dev/null
+++ b/apps/sim/app/api/v2/custom-tools/utils.ts
@@ -0,0 +1,46 @@
+import type { customTools } from '@sim/db/schema'
+import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors'
+import type { NextResponse } from 'next/server'
+import type { V2CustomTool } from '@/lib/api/contracts/v2/custom-tools'
+import { v2Error } from '@/app/api/v2/lib/response'
+
+/** Shared serialization + error mapping for the v2 custom tool surface. */
+
+/**
+ * Classifies a title collision as a conflict so it surfaces as 409 rather than a
+ * generic 500. Two distinct failures reach here and both must be covered:
+ *
+ * - `upsertCustomTools` throws its own message when its in-transaction duplicate
+ * `SELECT` finds one.
+ * - Under a concurrent create or rename, both callers pass that `SELECT` too, and
+ * the loser is rejected by `custom_tools_workspace_title_unique` as a raw
+ * Postgres `23505` — whose message matches nothing, which is exactly the race
+ * the message check alone cannot see.
+ */
+export function v2CustomToolWriteError(error: unknown): NextResponse | null {
+ if (getPostgresErrorCode(error) === '23505') {
+ return v2Error('CONFLICT', 'A custom tool with that title already exists in this workspace')
+ }
+ const message = getErrorMessage(error, '')
+ if (/already exists in this workspace/i.test(message)) {
+ return v2Error('CONFLICT', message)
+ }
+ return null
+}
+
+type CustomToolRow = typeof customTools.$inferSelect
+
+/**
+ * Public custom tool projection. `workspaceId` and `userId` are internal
+ * scoping columns and are not exposed.
+ */
+export function toV2CustomTool(row: CustomToolRow): V2CustomTool {
+ return {
+ id: row.id,
+ title: row.title,
+ schema: row.schema as V2CustomTool['schema'],
+ code: row.code,
+ createdAt: row.createdAt.toISOString(),
+ updatedAt: row.updatedAt.toISOString(),
+ }
+}
diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts
new file mode 100644
index 00000000000..3ce03cf3cdd
--- /dev/null
+++ b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts
@@ -0,0 +1,228 @@
+/**
+ * @vitest-environment node
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformUpdateContent } = vi.hoisted(
+ () => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceAccess: vi.fn(),
+ mockPerformUpdateContent: vi.fn(),
+ })
+)
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceAccess: mockResolveWorkspaceAccess,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+vi.mock('@/lib/workspace-files/orchestration', () => ({
+ MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024,
+ performUpdateWorkspaceFileContent: mockPerformUpdateContent,
+}))
+
+import { PUT } from '@/app/api/v2/files/[fileId]/content/route'
+
+const WS = 'workspace-1'
+const FILE_ID = 'wf_1'
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+}
+
+const RATE_LIMIT_DENIED = {
+ allowed: false,
+ limit: 100,
+ remaining: 0,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+ retryAfterMs: 1000,
+}
+
+const RECORD = {
+ id: FILE_ID,
+ workspaceId: WS,
+ name: 'data.csv',
+ key: 'workspace/ws/1-x-data.csv',
+ path: '/api/files/serve/x',
+ size: 8,
+ type: 'text/csv',
+ uploadedBy: 'user-1',
+ folderId: null,
+ folderPath: null,
+ uploadedAt: new Date('2024-01-01T00:00:00Z'),
+ updatedAt: new Date('2024-01-03T00:00:00Z'),
+}
+
+const callPut = (body: unknown, contentLength?: number) =>
+ PUT(
+ new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/content`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(contentLength === undefined ? {} : { 'Content-Length': String(contentLength) }),
+ },
+ body: typeof body === 'string' ? body : JSON.stringify(body),
+ }),
+ { params: Promise.resolve({ fileId: FILE_ID }) }
+ )
+
+describe('PUT /api/v2/files/[fileId]/content', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockPerformUpdateContent.mockResolvedValue({ success: true, file: RECORD })
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callPut({ workspaceId: WS, content: 'id,name\n' })
+
+ expect(res.status).toBe(404)
+ expect(mockPerformUpdateContent).not.toHaveBeenCalled()
+ })
+
+ it('400s when content is missing', async () => {
+ const res = await callPut({ workspaceId: WS })
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(mockPerformUpdateContent).not.toHaveBeenCalled()
+ })
+
+ it('400s on an encoding outside the enum', async () => {
+ const res = await callPut({ workspaceId: WS, content: 'x', encoding: 'latin1' })
+ expect(res.status).toBe(400)
+ expect(mockPerformUpdateContent).not.toHaveBeenCalled()
+ })
+
+ it('400s malformed base64 in the v2 error envelope', async () => {
+ const res = await callPut({ workspaceId: WS, content: 'not-base64!', encoding: 'base64' })
+ const body = await res.json()
+
+ expect(res.status).toBe(400)
+ expect(body.error.code).toBe('BAD_REQUEST')
+ expect(body.error.message).toBe('content must be valid base64')
+ expect(mockPerformUpdateContent).not.toHaveBeenCalled()
+ })
+
+ it('accepts empty base64 as a zero-byte replacement', async () => {
+ const res = await callPut({ workspaceId: WS, content: '', encoding: 'base64' })
+
+ expect(res.status).toBe(200)
+ expect(mockPerformUpdateContent).toHaveBeenCalledWith(
+ expect.objectContaining({ content: '', encoding: 'base64' })
+ )
+ })
+
+ it('allows JSON bodies above the default 50 MiB cap for base64 expansion', async () => {
+ const res = await callPut(
+ { workspaceId: WS, content: 'TQ==', encoding: 'base64' },
+ 60 * 1024 * 1024
+ )
+
+ expect(res.status).toBe(200)
+ expect(mockPerformUpdateContent).toHaveBeenCalled()
+ })
+
+ it('returns an oversized JSON body in the canonical v2 413 envelope', async () => {
+ const res = await callPut({ workspaceId: WS, content: '' }, 70 * 1024 * 1024 + 1)
+
+ expect(res.status).toBe(413)
+ await expect(res.json()).resolves.toEqual({
+ error: { code: 'PAYLOAD_TOO_LARGE', message: 'Request body is too large' },
+ })
+ expect(mockPerformUpdateContent).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue({
+ status: 403,
+ code: 'FORBIDDEN',
+ message: 'Access denied',
+ })
+ const res = await callPut({ workspaceId: WS, content: 'id,name\n' })
+ expect(res.status).toBe(403)
+ expect(mockPerformUpdateContent).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callPut({ workspaceId: WS, content: 'id,name\n' })
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('replaces the content and returns the updated file', async () => {
+ const res = await callPut({ workspaceId: WS, content: 'id,name\n' })
+ const body = await res.json()
+
+ expect(res.status).toBe(200)
+ expect(body.data).toEqual({
+ id: FILE_ID,
+ name: 'data.csv',
+ size: 8,
+ type: 'text/csv',
+ key: 'workspace/ws/1-x-data.csv',
+ folderPath: '/',
+ uploadedBy: 'user-1',
+ uploadedAt: '2024-01-01T00:00:00.000Z',
+ updatedAt: '2024-01-03T00:00:00.000Z',
+ })
+ expect(mockPerformUpdateContent).toHaveBeenCalledWith({
+ workspaceId: WS,
+ fileId: FILE_ID,
+ userId: 'user-1',
+ content: 'id,name\n',
+ encoding: 'utf-8',
+ request: expect.anything(),
+ })
+ })
+
+ it('forwards base64 encoding through to the orchestration', async () => {
+ await callPut({ workspaceId: WS, content: 'aWQsbmFtZQo=', encoding: 'base64' })
+ expect(mockPerformUpdateContent).toHaveBeenCalledWith(
+ expect.objectContaining({ encoding: 'base64' })
+ )
+ })
+
+ it('maps a payload_too_large errorCode to 413 rather than string-sniffing', async () => {
+ mockPerformUpdateContent.mockResolvedValue({
+ success: false,
+ error: 'Storage limit exceeded. Used: 5.10GB, Limit: 5GB',
+ errorCode: 'payload_too_large',
+ })
+
+ const res = await callPut({ workspaceId: WS, content: 'id,name\n' })
+ const body = await res.json()
+
+ expect(res.status).toBe(413)
+ expect(body.error.code).toBe('PAYLOAD_TOO_LARGE')
+ expect(body.error.message).toContain('Storage limit exceeded')
+ })
+
+ it('maps a not_found errorCode to 404', async () => {
+ mockPerformUpdateContent.mockResolvedValue({
+ success: false,
+ error: 'File not found',
+ errorCode: 'not_found',
+ })
+
+ const res = await callPut({ workspaceId: WS, content: 'id,name\n' })
+
+ expect(res.status).toBe(404)
+ expect((await res.json()).error.code).toBe('NOT_FOUND')
+ })
+})
diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.ts
new file mode 100644
index 00000000000..bb03695aa5f
--- /dev/null
+++ b/apps/sim/app/api/v2/files/[fileId]/content/route.ts
@@ -0,0 +1,89 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { v2UpdateFileContentContract } from '@/lib/api/contracts/v2/files'
+import { parseRequest } from '@/lib/api/server'
+import { messageForOrchestrationError } from '@/lib/core/orchestration/types'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ MAX_WORKSPACE_FILE_INLINE_BODY_BYTES,
+ performUpdateWorkspaceFileContent,
+} from '@/lib/workspace-files/orchestration'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { toV2File } from '@/app/api/v2/files/utils'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2Data,
+ v2Error,
+ v2ErrorForOrchestration,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2FileContentAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+interface FileRouteParams {
+ params: Promise<{ fileId: string }>
+}
+
+/**
+ * PUT /api/v2/files/[fileId]/content — Replace a file's bytes.
+ *
+ * A full replace, not an append: `content` becomes the entire body of the file.
+ * `encoding: 'base64'` carries non-UTF-8 bytes. The decoded body is capped at
+ * 50 MB and still debits the workspace storage quota, so a write that would push
+ * the payer past its limit fails with 413.
+ */
+export const PUT = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'file-content')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2UpdateFileContentContract, request, context, {
+ invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'),
+ maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES,
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) {
+ return parsed.response.status === 413
+ ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large')
+ : parsed.response
+ }
+
+ const { fileId } = parsed.data.params
+ const { workspaceId, content, encoding } = parsed.data.body
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const result = await performUpdateWorkspaceFileContent({
+ workspaceId,
+ fileId,
+ userId,
+ content,
+ encoding,
+ request,
+ })
+
+ if (!result.success || !result.file) {
+ return v2ErrorForOrchestration(
+ result.errorCode,
+ messageForOrchestrationError(result, 'Failed to update file content')
+ )
+ }
+
+ return v2Data(toV2File(result.file), { rateLimit })
+ } catch (error) {
+ logger.error('Error updating file content', { error: getErrorMessage(error, 'Unknown error') })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts
new file mode 100644
index 00000000000..309bb0b34c1
--- /dev/null
+++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts
@@ -0,0 +1,126 @@
+/**
+ * @vitest-environment node
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockGetWorkspaceFile } = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceAccess: vi.fn(),
+ mockGetWorkspaceFile: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceAccess: mockResolveWorkspaceAccess,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+vi.mock('@/lib/uploads/contexts/workspace', () => ({
+ getWorkspaceFile: mockGetWorkspaceFile,
+}))
+
+import { GET } from '@/app/api/v2/files/[fileId]/metadata/route'
+
+const WORKSPACE_ID = 'workspace-1'
+const FILE_ID = 'wf_1'
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+}
+
+const ctx = { params: Promise.resolve({ fileId: FILE_ID }) }
+
+function buildRecord() {
+ return {
+ id: FILE_ID,
+ workspaceId: WORKSPACE_ID,
+ name: 'data.csv',
+ key: 'workspace/ws/1-x-data.csv',
+ path: '/api/files/serve/x',
+ size: 1024,
+ type: 'text/csv',
+ uploadedBy: 'user-1',
+ folderId: null,
+ folderPath: null,
+ uploadedAt: new Date('2024-01-01T00:00:00Z'),
+ updatedAt: new Date('2024-01-02T00:00:00Z'),
+ }
+}
+
+const callGet = (query: string) =>
+ GET(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/metadata?${query}`), ctx)
+
+describe('GET /api/v2/files/[fileId]/metadata', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockGetWorkspaceFile.mockResolvedValue(buildRecord())
+ })
+
+ it('400s when workspaceId is missing', async () => {
+ const response = await callGet('')
+
+ expect(response.status).toBe(400)
+ expect(mockGetWorkspaceFile).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue({
+ status: 403,
+ code: 'FORBIDDEN',
+ message: 'Access denied',
+ })
+
+ const response = await callGet(`workspaceId=${WORKSPACE_ID}`)
+
+ expect(response.status).toBe(403)
+ expect(mockGetWorkspaceFile).not.toHaveBeenCalled()
+ })
+
+ it('404s when the workspace-scoped file does not exist', async () => {
+ mockGetWorkspaceFile.mockResolvedValue(null)
+
+ const response = await callGet(`workspaceId=${WORKSPACE_ID}`)
+
+ expect(response.status).toBe(404)
+ expect((await response.json()).error.code).toBe('NOT_FOUND')
+ })
+
+ it('returns the public metadata projection without loading content', async () => {
+ const response = await callGet(`workspaceId=${WORKSPACE_ID}`)
+
+ expect(response.status).toBe(200)
+ expect(await response.json()).toEqual({
+ data: {
+ id: FILE_ID,
+ name: 'data.csv',
+ size: 1024,
+ type: 'text/csv',
+ key: 'workspace/ws/1-x-data.csv',
+ folderPath: '/',
+ uploadedBy: 'user-1',
+ uploadedAt: '2024-01-01T00:00:00.000Z',
+ updatedAt: '2024-01-02T00:00:00.000Z',
+ },
+ })
+ expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(
+ expect.anything(),
+ 'user-1',
+ WORKSPACE_ID,
+ 'read'
+ )
+ expect(mockGetWorkspaceFile).toHaveBeenCalledWith(WORKSPACE_ID, FILE_ID, {
+ throwOnError: true,
+ })
+ })
+})
diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts
new file mode 100644
index 00000000000..69d1b8ce243
--- /dev/null
+++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts
@@ -0,0 +1,61 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { v2GetFileContract } from '@/lib/api/contracts/v2/files'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { toV2File } from '@/app/api/v2/files/utils'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2FileMetadataAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+interface FileMetadataRouteParams {
+ params: Promise<{ fileId: string }>
+}
+
+/** GET /api/v2/files/[fileId]/metadata — Return file metadata without downloading its bytes. */
+export const GET = withRouteHandler(
+ async (request: NextRequest, context: FileMetadataRouteParams) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'file-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2GetFileContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { fileId } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const file = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true })
+ if (!file) return v2Error('NOT_FOUND', 'File not found')
+
+ return v2Data(toV2File(file), { rateLimit })
+ } catch (error) {
+ logger.error('Error fetching file metadata', {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/files/[fileId]/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/route.test.ts
new file mode 100644
index 00000000000..eb0f2b3738a
--- /dev/null
+++ b/apps/sim/app/api/v2/files/[fileId]/route.test.ts
@@ -0,0 +1,332 @@
+/**
+ * @vitest-environment node
+ *
+ * Public v2 file detail: download, rename, archive. Covers the orchestration
+ * error mapping that replaced the route-local status switch.
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockResolveWorkspaceAccess,
+ mockGetWorkspaceFile,
+ mockFetchWorkspaceFileBuffer,
+ mockPerformRename,
+ mockPerformDelete,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceAccess: vi.fn(),
+ mockGetWorkspaceFile: vi.fn(),
+ mockFetchWorkspaceFileBuffer: vi.fn(),
+ mockPerformRename: vi.fn(),
+ mockPerformDelete: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceAccess: mockResolveWorkspaceAccess,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+vi.mock('@/lib/uploads/contexts/workspace', () => ({
+ getWorkspaceFile: mockGetWorkspaceFile,
+ fetchWorkspaceFileBuffer: mockFetchWorkspaceFileBuffer,
+}))
+
+vi.mock('@/lib/workspace-files/orchestration', () => ({
+ performRenameWorkspaceFile: mockPerformRename,
+ performDeleteWorkspaceFileItems: mockPerformDelete,
+}))
+
+import { DELETE, GET, PATCH } from '@/app/api/v2/files/[fileId]/route'
+
+const WS = 'workspace-1'
+const FILE_ID = 'wf_1'
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+}
+
+const RATE_LIMIT_DENIED = {
+ allowed: false,
+ limit: 100,
+ remaining: 0,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+ retryAfterMs: 1000,
+}
+
+function buildRecord(overrides: Record = {}) {
+ return {
+ id: FILE_ID,
+ workspaceId: WS,
+ name: 'data.csv',
+ key: 'workspace/ws/1-x-data.csv',
+ path: '/api/files/serve/x',
+ size: 1024,
+ type: 'text/csv',
+ uploadedBy: 'user-1',
+ folderId: null,
+ folderPath: null,
+ uploadedAt: new Date('2024-01-01T00:00:00Z'),
+ updatedAt: new Date('2024-01-02T00:00:00Z'),
+ ...overrides,
+ }
+}
+
+const ctx = { params: Promise.resolve({ fileId: FILE_ID }) }
+
+const callDownload = (query: string) =>
+ GET(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?${query}`), ctx)
+
+const callRename = (body: unknown) =>
+ PATCH(
+ new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ }),
+ ctx
+ )
+
+const callDelete = (query: string) =>
+ DELETE(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?${query}`), ctx)
+
+describe('GET /api/v2/files/[fileId]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockGetWorkspaceFile.mockResolvedValue(buildRecord())
+ mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('id,name\n'))
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callDownload(`workspaceId=${WS}`)
+
+ expect(res.status).toBe(404)
+ expect(mockGetWorkspaceFile).not.toHaveBeenCalled()
+ })
+
+ it('400s when workspaceId is missing', async () => {
+ const res = await callDownload('')
+ expect(res.status).toBe(400)
+ expect(mockGetWorkspaceFile).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue({
+ status: 403,
+ code: 'FORBIDDEN',
+ message: 'Access denied',
+ })
+ const res = await callDownload(`workspaceId=${WS}`)
+ expect(res.status).toBe(403)
+ expect(mockGetWorkspaceFile).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callDownload(`workspaceId=${WS}`)
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('streams the bytes with rate-limit headers', async () => {
+ const res = await callDownload(`workspaceId=${WS}`)
+ expect(res.status).toBe(200)
+ expect(res.headers.get('Content-Type')).toBe('text/csv')
+ expect(res.headers.get('X-RateLimit-Remaining')).toBe('99')
+ expect(await res.text()).toBe('id,name\n')
+ })
+})
+
+describe('PATCH /api/v2/files/[fileId]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockPerformRename.mockResolvedValue({
+ success: true,
+ file: buildRecord({ name: 'renamed.csv' }),
+ })
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callRename({ workspaceId: WS, name: 'renamed.csv' })
+
+ expect(res.status).toBe(404)
+ expect(mockPerformRename).not.toHaveBeenCalled()
+ })
+
+ it('400s on a name containing a path separator', async () => {
+ const res = await callRename({ workspaceId: WS, name: 'nested/renamed.csv' })
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(mockPerformRename).not.toHaveBeenCalled()
+ })
+
+ it('400s on an unknown body field', async () => {
+ const res = await callRename({ workspaceId: WS, name: 'renamed.csv', folderId: 'fold_1' })
+ expect(res.status).toBe(400)
+ expect(mockPerformRename).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue({
+ status: 403,
+ code: 'FORBIDDEN',
+ message: 'Access denied',
+ })
+ const res = await callRename({ workspaceId: WS, name: 'renamed.csv' })
+ expect(res.status).toBe(403)
+ expect(mockPerformRename).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callRename({ workspaceId: WS, name: 'renamed.csv' })
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('renames and returns the public file shape', async () => {
+ const res = await callRename({ workspaceId: WS, name: 'renamed.csv' })
+ const body = await res.json()
+
+ expect(res.status).toBe(200)
+ expect(body.data).toEqual({
+ id: FILE_ID,
+ name: 'renamed.csv',
+ size: 1024,
+ type: 'text/csv',
+ key: 'workspace/ws/1-x-data.csv',
+ folderPath: '/',
+ uploadedBy: 'user-1',
+ uploadedAt: '2024-01-01T00:00:00.000Z',
+ updatedAt: '2024-01-02T00:00:00.000Z',
+ })
+ expect(mockPerformRename).toHaveBeenCalledWith({
+ workspaceId: WS,
+ fileId: FILE_ID,
+ name: 'renamed.csv',
+ userId: 'user-1',
+ })
+ })
+
+ it('maps a conflict errorCode to 409 through the shared mapper', async () => {
+ mockPerformRename.mockResolvedValue({
+ success: false,
+ error: 'A file named "renamed.csv" already exists in this workspace',
+ errorCode: 'conflict',
+ })
+
+ const res = await callRename({ workspaceId: WS, name: 'renamed.csv' })
+ const body = await res.json()
+
+ expect(res.status).toBe(409)
+ expect(body.error.code).toBe('CONFLICT')
+ expect(body.error.message).toContain('already exists')
+ })
+
+ it('hides an unclassified failure behind a generic 500', async () => {
+ mockPerformRename.mockResolvedValue({
+ success: false,
+ error: 'update "workspace_files" set ... failed',
+ errorCode: 'internal',
+ })
+
+ const res = await callRename({ workspaceId: WS, name: 'renamed.csv' })
+ const body = await res.json()
+
+ expect(res.status).toBe(500)
+ expect(body.error.message).toBe('Internal server error')
+ })
+})
+
+describe('DELETE /api/v2/files/[fileId]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockPerformDelete.mockResolvedValue({ success: true, deletedItems: { files: 1, folders: 0 } })
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callDelete(`workspaceId=${WS}`)
+
+ expect(res.status).toBe(404)
+ expect(mockPerformDelete).not.toHaveBeenCalled()
+ })
+
+ it('400s when workspaceId is missing', async () => {
+ const res = await callDelete('')
+ expect(res.status).toBe(400)
+ expect(mockPerformDelete).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue({
+ status: 403,
+ code: 'FORBIDDEN',
+ message: 'Access denied',
+ })
+ const res = await callDelete(`workspaceId=${WS}`)
+ expect(res.status).toBe(403)
+ expect(mockPerformDelete).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callDelete(`workspaceId=${WS}`)
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('archives the file and acknowledges', async () => {
+ const res = await callDelete(`workspaceId=${WS}`)
+ const body = await res.json()
+
+ expect(res.status).toBe(200)
+ expect(body.data).toEqual({ id: FILE_ID, deleted: true })
+ expect(mockPerformDelete).toHaveBeenCalledWith({
+ workspaceId: WS,
+ userId: 'user-1',
+ fileIds: [FILE_ID],
+ request: expect.anything(),
+ })
+ })
+
+ it('maps a not_found errorCode to 404', async () => {
+ mockPerformDelete.mockResolvedValue({
+ success: false,
+ error: 'File not found',
+ errorCode: 'not_found',
+ })
+
+ const res = await callDelete(`workspaceId=${WS}`)
+
+ expect(res.status).toBe(404)
+ expect((await res.json()).error.code).toBe('NOT_FOUND')
+ })
+})
diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts
new file mode 100644
index 00000000000..133d5debeb7
--- /dev/null
+++ b/apps/sim/app/api/v2/files/[fileId]/route.ts
@@ -0,0 +1,181 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import {
+ v2DeleteFileContract,
+ v2DownloadFileContract,
+ v2RenameFileContract,
+} from '@/lib/api/contracts/v2/files'
+import { parseRequest } from '@/lib/api/server'
+import { messageForOrchestrationError } from '@/lib/core/orchestration/types'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
+import {
+ performDeleteWorkspaceFileItems,
+ performRenameWorkspaceFile,
+} from '@/lib/workspace-files/orchestration'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { toV2File } from '@/app/api/v2/files/utils'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ rateLimitHeaders,
+ v2Data,
+ v2Error,
+ v2ErrorForOrchestration,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2FileDetailAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+interface FileRouteParams {
+ params: Promise<{ fileId: string }>
+}
+
+/**
+ * GET /api/v2/files/[fileId] — Download file content (binary).
+ *
+ * The response carries no JSON envelope, so rate-limit state is surfaced via
+ * `X-RateLimit-*` headers. Errors still render the canonical v2 JSON error body.
+ * Lookups are workspace-scoped (IDOR-safe): a file in another workspace 404s.
+ */
+export const GET = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'file-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2DownloadFileContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { fileId } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const fileRecord = await getWorkspaceFile(workspaceId, fileId)
+ if (!fileRecord) return v2Error('NOT_FOUND', 'File not found')
+
+ const buffer = await fetchWorkspaceFileBuffer(fileRecord)
+
+ return new Response(new Uint8Array(buffer), {
+ status: 200,
+ headers: {
+ 'Content-Type': fileRecord.type || 'application/octet-stream',
+ 'Content-Disposition': `attachment; filename="${fileRecord.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(fileRecord.name)}`,
+ 'Content-Length': String(buffer.length),
+ ...rateLimitHeaders(rateLimit),
+ },
+ })
+ } catch (error) {
+ logger.error('Error downloading file', { error: getErrorMessage(error, 'Unknown error') })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/**
+ * PATCH /api/v2/files/[fileId] — Rename a file.
+ *
+ * Renaming only; use `POST /api/v2/files/move` to change a file's folder.
+ * Names that collide within the destination folder are rejected as `CONFLICT` —
+ * unlike upload, which auto-suffixes on the internal surface.
+ */
+export const PATCH = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'file-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2RenameFileContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { fileId } = parsed.data.params
+ const { workspaceId, name } = parsed.data.body
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const result = await performRenameWorkspaceFile({ workspaceId, fileId, name, userId })
+
+ if (!result.success || !result.file) {
+ return v2ErrorForOrchestration(
+ result.errorCode,
+ messageForOrchestrationError(result, 'Failed to rename file')
+ )
+ }
+
+ return v2Data(toV2File(result.file), { rateLimit })
+ } catch (error) {
+ logger.error('Error renaming file', { error: getErrorMessage(error, 'Unknown error') })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/**
+ * DELETE /api/v2/files/[fileId] — Delete a file.
+ *
+ * Delegates to the shared orchestration, which is workspace-scoped and records
+ * its own audit entry (the request is forwarded so that entry captures client
+ * IP / user agent). Orchestration `errorCode`s map to specific v2 codes rather
+ * than v1's blanket 500.
+ */
+export const DELETE = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'file-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2DeleteFileContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { fileId } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const result = await performDeleteWorkspaceFileItems({
+ workspaceId,
+ userId,
+ fileIds: [fileId],
+ request,
+ })
+
+ if (!result.success) {
+ return v2ErrorForOrchestration(
+ result.errorCode,
+ messageForOrchestrationError(result, 'Failed to delete file')
+ )
+ }
+
+ logger.info(`Deleted file ${fileId} from workspace ${workspaceId}`)
+
+ return v2Data({ id: fileId, deleted: true as const }, { rateLimit })
+ } catch (error) {
+ logger.error('Error deleting file', { error: getErrorMessage(error, 'Unknown error') })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts
new file mode 100644
index 00000000000..25b15d2f47b
--- /dev/null
+++ b/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts
@@ -0,0 +1,271 @@
+/**
+ * @vitest-environment node
+ *
+ * Public v2 file share. The two decisions that separate it from the internal
+ * route are pinned here: the caller-supplied `token` is rejected, and a bare
+ * re-enable keeps the token the orchestration already stored.
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformGetShare, mockPerformUpsert } =
+ vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceAccess: vi.fn(),
+ mockPerformGetShare: vi.fn(),
+ mockPerformUpsert: vi.fn(),
+ }))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceAccess: mockResolveWorkspaceAccess,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+vi.mock('@/lib/workspace-files/orchestration', () => ({
+ performGetWorkspaceFileShare: mockPerformGetShare,
+ performUpsertWorkspaceFileShare: mockPerformUpsert,
+}))
+
+import { GET, PUT } from '@/app/api/v2/files/[fileId]/share/route'
+
+const WS = 'workspace-1'
+const FILE_ID = 'wf_1'
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+}
+
+const RATE_LIMIT_DENIED = {
+ allowed: false,
+ limit: 100,
+ remaining: 0,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+ retryAfterMs: 1000,
+}
+
+const SHARE = {
+ id: 'shr_1',
+ token: 'existing-token-abcd',
+ url: 'https://www.sim.ai/f/existing-token-abcd',
+ isActive: true,
+ resourceType: 'file' as const,
+ resourceId: FILE_ID,
+ authType: 'public' as const,
+ hasPassword: false,
+ allowedEmails: [] as string[],
+}
+
+const ctx = { params: Promise.resolve({ fileId: FILE_ID }) }
+
+const callGet = (query: string) =>
+ GET(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/share?${query}`), ctx)
+
+const callPut = (body: unknown) =>
+ PUT(
+ new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/share`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ }),
+ ctx
+ )
+
+describe('GET /api/v2/files/[fileId]/share', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockPerformGetShare.mockResolvedValue({ success: true, share: SHARE })
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callGet(`workspaceId=${WS}`)
+
+ expect(res.status).toBe(404)
+ expect(mockPerformGetShare).not.toHaveBeenCalled()
+ })
+
+ it('400s when workspaceId is missing', async () => {
+ const res = await callGet('')
+ expect(res.status).toBe(400)
+ expect(mockPerformGetShare).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue({
+ status: 403,
+ code: 'FORBIDDEN',
+ message: 'Access denied',
+ })
+ const res = await callGet(`workspaceId=${WS}`)
+ expect(res.status).toBe(403)
+ expect(mockPerformGetShare).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callGet(`workspaceId=${WS}`)
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('reads at workspace read level and returns the share', async () => {
+ const res = await callGet(`workspaceId=${WS}`)
+ const body = await res.json()
+
+ expect(res.status).toBe(200)
+ expect(body.data).toEqual({ share: SHARE })
+ expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(expect.anything(), 'user-1', WS, 'read')
+ expect(mockPerformGetShare).toHaveBeenCalledWith({ workspaceId: WS, fileId: FILE_ID })
+ })
+
+ it('returns a null share for a file that was never shared', async () => {
+ mockPerformGetShare.mockResolvedValue({ success: true, share: null })
+ const res = await callGet(`workspaceId=${WS}`)
+ expect((await res.json()).data).toEqual({ share: null })
+ })
+})
+
+describe('PUT /api/v2/files/[fileId]/share', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockPerformUpsert.mockResolvedValue({ success: true, share: SHARE })
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callPut({ workspaceId: WS, isActive: true })
+
+ expect(res.status).toBe(404)
+ expect(mockPerformUpsert).not.toHaveBeenCalled()
+ })
+
+ it('400s when isActive is missing', async () => {
+ const res = await callPut({ workspaceId: WS })
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(mockPerformUpsert).not.toHaveBeenCalled()
+ })
+
+ it('rejects a caller-supplied token instead of minting a predictable URL', async () => {
+ const res = await callPut({
+ workspaceId: WS,
+ isActive: true,
+ token: 'attacker-chosen-token',
+ })
+
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(mockPerformUpsert).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue({
+ status: 403,
+ code: 'FORBIDDEN',
+ message: 'Access denied',
+ })
+ const res = await callPut({ workspaceId: WS, isActive: true })
+ expect(res.status).toBe(403)
+ expect(mockPerformUpsert).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callPut({ workspaceId: WS, isActive: true })
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('enables the share at workspace write level and never forwards a token', async () => {
+ const res = await callPut({
+ workspaceId: WS,
+ isActive: true,
+ authType: 'password',
+ password: 'hunter2hunter2',
+ })
+ const body = await res.json()
+
+ expect(res.status).toBe(200)
+ expect(body.data).toEqual({ share: SHARE })
+ expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(
+ expect.anything(),
+ 'user-1',
+ WS,
+ 'write'
+ )
+ expect(mockPerformUpsert).toHaveBeenCalledWith({
+ workspaceId: WS,
+ fileId: FILE_ID,
+ userId: 'user-1',
+ isActive: true,
+ authType: 'password',
+ password: 'hunter2hunter2',
+ allowedEmails: undefined,
+ request: expect.anything(),
+ })
+ expect(mockPerformUpsert.mock.calls[0][0]).not.toHaveProperty('token')
+ })
+
+ it('preserves the existing token on a bare re-enable', async () => {
+ const res = await callPut({ workspaceId: WS, isActive: true })
+ const body = await res.json()
+
+ expect(res.status).toBe(200)
+ expect(body.data.share.token).toBe('existing-token-abcd')
+ expect(body.data.share.url).toBe('https://www.sim.ai/f/existing-token-abcd')
+ // No authType either: the orchestration resolves the stored one, so the
+ // access-control gate is evaluated against the real mode, not 'public'.
+ expect(mockPerformUpsert).toHaveBeenCalledWith(
+ expect.objectContaining({ isActive: true, authType: undefined })
+ )
+ })
+
+ it('maps a forbidden errorCode from the access-control policy to 403', async () => {
+ mockPerformUpsert.mockResolvedValue({
+ success: false,
+ error: 'Public file sharing is not allowed based on your permission group settings',
+ errorCode: 'forbidden',
+ })
+
+ const res = await callPut({ workspaceId: WS, isActive: true })
+ const body = await res.json()
+
+ expect(res.status).toBe(403)
+ expect(body.error.code).toBe('FORBIDDEN')
+ expect(body.error.message).toContain('not allowed')
+ })
+
+ it('maps a validation errorCode to 400', async () => {
+ mockPerformUpsert.mockResolvedValue({
+ success: false,
+ error: 'Password is required for password-protected shares',
+ errorCode: 'validation',
+ })
+
+ const res = await callPut({ workspaceId: WS, isActive: true, authType: 'password' })
+
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.message).toBe(
+ 'Password is required for password-protected shares'
+ )
+ })
+})
diff --git a/apps/sim/app/api/v2/files/[fileId]/share/route.ts b/apps/sim/app/api/v2/files/[fileId]/share/route.ts
new file mode 100644
index 00000000000..088672432c5
--- /dev/null
+++ b/apps/sim/app/api/v2/files/[fileId]/share/route.ts
@@ -0,0 +1,130 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { v2GetFileShareContract, v2UpsertFileShareContract } from '@/lib/api/contracts/v2/files'
+import { parseRequest } from '@/lib/api/server'
+import { messageForOrchestrationError } from '@/lib/core/orchestration/types'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ performGetWorkspaceFileShare,
+ performUpsertWorkspaceFileShare,
+} from '@/lib/workspace-files/orchestration'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2Data,
+ v2Error,
+ v2ErrorForOrchestration,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2FileShareAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+interface FileRouteParams {
+ params: Promise<{ fileId: string }>
+}
+
+/**
+ * GET /api/v2/files/[fileId]/share — Read a file's public share state.
+ *
+ * `null` means the file has never been shared. `hasPassword` is the only signal
+ * carried for a password-gated share; the ciphertext is never exposed.
+ */
+export const GET = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'file-share')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2GetFileShareContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { fileId } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const result = await performGetWorkspaceFileShare({ workspaceId, fileId })
+
+ if (!result.success) {
+ return v2ErrorForOrchestration(
+ result.errorCode,
+ messageForOrchestrationError(result, 'Failed to fetch share')
+ )
+ }
+
+ return v2Data({ share: result.share ?? null }, { rateLimit })
+ } catch (error) {
+ logger.error('Error fetching file share', { error: getErrorMessage(error, 'Unknown error') })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/**
+ * PUT /api/v2/files/[fileId]/share — Enable or disable a file's public share.
+ *
+ * Requires workspace `write`, matching the UI. The share token is always
+ * server-generated: the internal surface accepts a caller-supplied one so the UI
+ * can render a link before saving, but over an API key that would mint
+ * predictable public URLs and collide with the token unique index.
+ *
+ * `isActive: false` disables, it does not revoke — the token and the stored
+ * password / allow-list survive, so re-enabling resurrects the same URL.
+ */
+export const PUT = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'file-share')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2UpsertFileShareContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { fileId } = parsed.data.params
+ const { workspaceId, isActive, authType, password, allowedEmails } = parsed.data.body
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const result = await performUpsertWorkspaceFileShare({
+ workspaceId,
+ fileId,
+ userId,
+ isActive,
+ authType,
+ password,
+ allowedEmails,
+ request,
+ })
+
+ if (!result.success || !result.share) {
+ return v2ErrorForOrchestration(
+ result.errorCode,
+ messageForOrchestrationError(result, 'Failed to update share')
+ )
+ }
+
+ return v2Data({ share: result.share }, { rateLimit })
+ } catch (error) {
+ logger.error('Error updating file share', { error: getErrorMessage(error, 'Unknown error') })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/files/bulk-delete/route.test.ts b/apps/sim/app/api/v2/files/bulk-delete/route.test.ts
new file mode 100644
index 00000000000..31795501de6
--- /dev/null
+++ b/apps/sim/app/api/v2/files/bulk-delete/route.test.ts
@@ -0,0 +1,126 @@
+/**
+ * @vitest-environment node
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformDelete } = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceAccess: vi.fn(),
+ mockPerformDelete: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceAccess: mockResolveWorkspaceAccess,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+vi.mock('@/lib/workspace-files/orchestration', () => ({
+ performDeleteWorkspaceFileItems: mockPerformDelete,
+}))
+
+import { POST } from '@/app/api/v2/files/bulk-delete/route'
+
+const WS = 'workspace-1'
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+}
+
+const RATE_LIMIT_DENIED = {
+ allowed: false,
+ limit: 100,
+ remaining: 0,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+ retryAfterMs: 1000,
+}
+
+const callDelete = (body: unknown) =>
+ POST(
+ new NextRequest('http://localhost:3000/api/v2/files/bulk-delete', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ )
+
+describe('POST /api/v2/files/bulk-delete', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockPerformDelete.mockResolvedValue({ success: true, deletedItems: { files: 3, folders: 1 } })
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] })
+
+ expect(res.status).toBe(404)
+ expect(mockPerformDelete).not.toHaveBeenCalled()
+ })
+
+ it('400s when the selection is empty', async () => {
+ const res = await callDelete({ workspaceId: WS, fileIds: [] })
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(mockPerformDelete).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue({
+ status: 403,
+ code: 'FORBIDDEN',
+ message: 'Access denied',
+ })
+ const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] })
+ expect(res.status).toBe(403)
+ expect(mockPerformDelete).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] })
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('deletes the selection and reports the file count', async () => {
+ const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] })
+ const body = await res.json()
+
+ expect(res.status).toBe(200)
+ expect(body.data).toEqual({ deletedItems: { files: 3 } })
+ expect(mockPerformDelete).toHaveBeenCalledWith({
+ workspaceId: WS,
+ userId: 'user-1',
+ fileIds: ['wf_1'],
+ request: expect.anything(),
+ })
+ })
+
+ it('maps a not_found errorCode to 404', async () => {
+ mockPerformDelete.mockResolvedValue({
+ success: false,
+ error: 'File not found',
+ errorCode: 'not_found',
+ })
+
+ const res = await callDelete({ workspaceId: WS, fileIds: ['wf_missing'] })
+
+ expect(res.status).toBe(404)
+ expect((await res.json()).error.code).toBe('NOT_FOUND')
+ })
+})
diff --git a/apps/sim/app/api/v2/files/bulk-delete/route.ts b/apps/sim/app/api/v2/files/bulk-delete/route.ts
new file mode 100644
index 00000000000..e0bd99a4b89
--- /dev/null
+++ b/apps/sim/app/api/v2/files/bulk-delete/route.ts
@@ -0,0 +1,71 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { v2BulkDeleteFilesContract } from '@/lib/api/contracts/v2/files'
+import { parseRequest } from '@/lib/api/server'
+import { messageForOrchestrationError } from '@/lib/core/orchestration/types'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2Data,
+ v2Error,
+ v2ErrorForOrchestration,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2FileBulkDeleteAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+/**
+ * POST /api/v2/files/bulk-delete — Delete files. Folder deletion is owned by
+ * `/api/v2/files/folders` so this resource operation never accepts folder ids.
+ */
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'file-bulk-delete')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2BulkDeleteFilesContract,
+ request,
+ {},
+ { validationErrorResponse: v2ValidationError }
+ )
+ if (!parsed.success) return parsed.response
+
+ const { workspaceId, fileIds } = parsed.data.body
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const result = await performDeleteWorkspaceFileItems({
+ workspaceId,
+ userId,
+ fileIds,
+ request,
+ })
+
+ if (!result.success || !result.deletedItems) {
+ return v2ErrorForOrchestration(
+ result.errorCode,
+ messageForOrchestrationError(result, 'Failed to delete files')
+ )
+ }
+
+ return v2Data({ deletedItems: { files: result.deletedItems.files } }, { rateLimit })
+ } catch (error) {
+ logger.error('Error deleting files', { error: getErrorMessage(error, 'Unknown error') })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/files/folders/route.ts b/apps/sim/app/api/v2/files/folders/route.ts
new file mode 100644
index 00000000000..12b73d316f1
--- /dev/null
+++ b/apps/sim/app/api/v2/files/folders/route.ts
@@ -0,0 +1,172 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import {
+ v2CreateFileFolderContract,
+ v2DeleteFileFolderContract,
+ v2ListFileFoldersContract,
+ v2RelocateFileFolderContract,
+} from '@/lib/api/contracts/v2/files'
+import { parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { toFolderPathView } from '@/lib/folders/paths'
+import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries'
+import {
+ performCreateWorkspaceFileFolderAtPath,
+ performDeleteWorkspaceFileFolderByPath,
+ performRelocateWorkspaceFileFolderByPath,
+} from '@/lib/workspace-files/orchestration/file-folder-lifecycle'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import {
+ resolveFolderPathId,
+ toV2PathFolder,
+ v2FolderPathMutationError,
+} from '@/app/api/v2/lib/folders'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CursorList,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2FileFoldersAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+export const GET = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+ try {
+ const rateLimit = await checkRateLimit(request, 'files')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(
+ v2ListFileFoldersContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+ const { workspaceId, parentPath, search, sortBy, sortOrder } = parsed.data.query
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const index = await loadActiveFolderPathIndex(workspaceId, 'file')
+ const parentId = parentPath === undefined ? undefined : resolveFolderPathId(index, parentPath)
+ if (parentPath !== undefined && parentId === undefined) {
+ return v2Error('NOT_FOUND', 'Folder not found')
+ }
+ const rows = await listActiveFolderRows(workspaceId, 'file', {
+ parentId,
+ search,
+ sortBy,
+ sortOrder,
+ })
+ return v2CursorList(
+ rows.map((row) => toV2PathFolder(row, index, false)),
+ null,
+ { rateLimit }
+ )
+ } catch (error) {
+ logger.error(`[${requestId}] Error listing file folders`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const rateLimit = await checkRateLimit(request, 'files')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(
+ v2CreateFileFolderContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+ const { workspaceId, path } = parsed.data.body
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+ const result = await performCreateWorkspaceFileFolderAtPath({ workspaceId, userId, path })
+ if (!result.success || !result.folder || !result.path) {
+ return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder')
+ }
+ return v2Data(
+ { folder: toFolderPathView(result.folder, result.path) },
+ { rateLimit, status: 201 }
+ )
+})
+
+export const PATCH = withRouteHandler(async (request: NextRequest) => {
+ const rateLimit = await checkRateLimit(request, 'files')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(
+ v2RelocateFileFolderContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+ const { workspaceId, path, destinationPath } = parsed.data.body
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+ const result = await performRelocateWorkspaceFileFolderByPath({
+ workspaceId,
+ userId,
+ path,
+ destinationPath,
+ })
+ if (!result.success || !result.folder || !result.path) {
+ return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder')
+ }
+ return v2Data({ folder: toFolderPathView(result.folder, result.path) }, { rateLimit })
+})
+
+export const DELETE = withRouteHandler(async (request: NextRequest) => {
+ const rateLimit = await checkRateLimit(request, 'files')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(
+ v2DeleteFileFolderContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+ const { workspaceId, path, recursive } = parsed.data.query
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+ const result = await performDeleteWorkspaceFileFolderByPath({
+ workspaceId,
+ userId,
+ path,
+ recursive,
+ })
+ if (!result.success || !result.deletedItems) {
+ return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder')
+ }
+ return v2Data({ path, deleted: true as const, deletedItems: result.deletedItems }, { rateLimit })
+})
diff --git a/apps/sim/app/api/v2/files/move/route.test.ts b/apps/sim/app/api/v2/files/move/route.test.ts
new file mode 100644
index 00000000000..b6651476b8f
--- /dev/null
+++ b/apps/sim/app/api/v2/files/move/route.test.ts
@@ -0,0 +1,142 @@
+/**
+ * @vitest-environment node
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformMove } = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceAccess: vi.fn(),
+ mockPerformMove: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceAccess: mockResolveWorkspaceAccess,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+vi.mock('@/lib/workspace-files/orchestration', () => ({
+ performMoveWorkspaceFileItems: mockPerformMove,
+}))
+
+import { POST } from '@/app/api/v2/files/move/route'
+
+const WS = 'workspace-1'
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+}
+
+const RATE_LIMIT_DENIED = {
+ allowed: false,
+ limit: 100,
+ remaining: 0,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+ retryAfterMs: 1000,
+}
+
+const callMove = (body: unknown) =>
+ POST(
+ new NextRequest('http://localhost:3000/api/v2/files/move', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ )
+
+describe('POST /api/v2/files/move', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockPerformMove.mockResolvedValue({ success: true, movedItems: { files: 2, folders: 0 } })
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'] })
+
+ expect(res.status).toBe(404)
+ expect(mockPerformMove).not.toHaveBeenCalled()
+ })
+
+ it('400s when the selection is empty', async () => {
+ const res = await callMove({ workspaceId: WS })
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(mockPerformMove).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue({
+ status: 403,
+ code: 'FORBIDDEN',
+ message: 'Access denied',
+ })
+ const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'] })
+ expect(res.status).toBe(403)
+ expect(mockPerformMove).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'] })
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('moves the selection into the target folder', async () => {
+ const res = await callMove({
+ workspaceId: WS,
+ fileIds: ['wf_1', 'wf_2'],
+ targetFolderPath: '/Reports',
+ })
+ const body = await res.json()
+
+ expect(res.status).toBe(200)
+ expect(body.data).toEqual({ movedItems: { files: 2 } })
+ expect(mockPerformMove).toHaveBeenCalledWith({
+ workspaceId: WS,
+ userId: 'user-1',
+ fileIds: ['wf_1', 'wf_2'],
+ targetFolderPath: '/Reports',
+ })
+ })
+
+ it('treats an omitted targetFolderPath as the workspace root', async () => {
+ await callMove({ workspaceId: WS, fileIds: ['wf_1'] })
+ expect(mockPerformMove).toHaveBeenCalledWith(
+ expect.objectContaining({ fileIds: ['wf_1'], targetFolderPath: '/' })
+ )
+ })
+
+ it('maps a conflict errorCode to 409 without partially applying', async () => {
+ mockPerformMove.mockResolvedValue({
+ success: false,
+ error: 'A file named "data.csv" already exists in the destination folder',
+ errorCode: 'conflict',
+ })
+
+ const res = await callMove({
+ workspaceId: WS,
+ fileIds: ['wf_1'],
+ targetFolderPath: '/Reports',
+ })
+ const body = await res.json()
+
+ expect(res.status).toBe(409)
+ expect(body.error.code).toBe('CONFLICT')
+ })
+})
diff --git a/apps/sim/app/api/v2/files/move/route.ts b/apps/sim/app/api/v2/files/move/route.ts
new file mode 100644
index 00000000000..fa4f8b0053c
--- /dev/null
+++ b/apps/sim/app/api/v2/files/move/route.ts
@@ -0,0 +1,75 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { v2MoveFileItemsContract } from '@/lib/api/contracts/v2/files'
+import { parseRequest } from '@/lib/api/server'
+import { messageForOrchestrationError } from '@/lib/core/orchestration/types'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2Data,
+ v2Error,
+ v2ErrorForOrchestration,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2FileMoveAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+/**
+ * POST /api/v2/files/move — Move files into a folder.
+ *
+ * An omitted `targetFolderPath` moves the selection to the
+ * workspace root. The whole selection moves under one advisory lock, so a name
+ * collision at the destination fails the request as `CONFLICT` rather than
+ * partially applying.
+ */
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'file-move')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2MoveFileItemsContract,
+ request,
+ {},
+ { validationErrorResponse: v2ValidationError }
+ )
+ if (!parsed.success) return parsed.response
+
+ const { workspaceId, fileIds, targetFolderPath } = parsed.data.body
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const result = await performMoveWorkspaceFileItems({
+ workspaceId,
+ userId,
+ fileIds,
+ targetFolderPath: targetFolderPath ?? '/',
+ })
+
+ if (!result.success || !result.movedItems) {
+ return v2ErrorForOrchestration(
+ result.errorCode,
+ messageForOrchestrationError(result, 'Failed to move file items')
+ )
+ }
+
+ return v2Data({ movedItems: { files: result.movedItems.files } }, { rateLimit })
+ } catch (error) {
+ logger.error('Error moving file items', { error: getErrorMessage(error, 'Unknown error') })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/files/route.test.ts b/apps/sim/app/api/v2/files/route.test.ts
new file mode 100644
index 00000000000..d4e4cee44ed
--- /dev/null
+++ b/apps/sim/app/api/v2/files/route.test.ts
@@ -0,0 +1,515 @@
+/**
+ * @vitest-environment node
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockPerformCreateWorkspaceFile,
+ mockQueryWorkspaceFiles,
+ mockResolveWorkspaceAccess,
+ mockV2ApiGateError,
+ mockLoadActiveFolderPathIndex,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockPerformCreateWorkspaceFile: vi.fn(),
+ mockResolveWorkspaceAccess: vi.fn(),
+ mockQueryWorkspaceFiles: vi.fn(),
+ mockV2ApiGateError: vi.fn().mockResolvedValue(null),
+ mockLoadActiveFolderPathIndex: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceAccess: mockResolveWorkspaceAccess,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: mockV2ApiGateError,
+}))
+
+vi.mock('@/lib/uploads/contexts/workspace', () => ({
+ queryWorkspaceFiles: mockQueryWorkspaceFiles,
+}))
+
+vi.mock('@/lib/folders/queries', () => ({
+ loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex,
+}))
+
+vi.mock('@/lib/workspace-files/orchestration', () => ({
+ MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024,
+ performCreateWorkspaceFile: mockPerformCreateWorkspaceFile,
+}))
+
+import { OrchestrationError } from '@/lib/core/orchestration/types'
+import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration'
+import { GET, POST } from '@/app/api/v2/files/route'
+
+const WS = 'workspace-1'
+const FOLDER_ID = 'fold_1'
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+}
+
+const RATE_LIMIT_DENIED = {
+ allowed: false,
+ limit: 100,
+ remaining: 0,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+ retryAfterMs: 1000,
+}
+
+function buildRecord(overrides: Record = {}) {
+ return {
+ id: 'wf_1',
+ workspaceId: WS,
+ name: 'data.csv',
+ key: 'workspace/ws/1-x-data.csv',
+ path: '/api/files/serve/x',
+ size: 1024,
+ type: 'text/csv',
+ uploadedBy: 'user-1',
+ folderId: null,
+ folderPath: null,
+ uploadedAt: new Date('2024-01-01T00:00:00Z'),
+ updatedAt: new Date('2024-01-02T00:00:00Z'),
+ ...overrides,
+ }
+}
+
+/** What the route forwards for a bare `?workspaceId=` list. */
+const DEFAULT_LIST_ARGS = {
+ folderId: undefined,
+ search: undefined,
+ sortBy: 'uploadedAt',
+ sortOrder: 'asc',
+ limit: 100,
+ after: undefined,
+}
+
+const callList = (query: string) =>
+ GET(new NextRequest(`http://localhost:3000/api/v2/files?${query}`))
+
+function createRequest(body: Record) {
+ return new NextRequest('http://localhost:3000/api/v2/files', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+}
+
+describe('GET /api/v2/files', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockQueryWorkspaceFiles.mockResolvedValue({ files: [buildRecord()], nextKeys: null })
+ mockLoadActiveFolderPathIndex.mockResolvedValue({
+ rowById: new Map([['fold_1', { id: 'fold_1', name: 'Reports', parentId: null }]]),
+ pathById: new Map([['fold_1', '/Reports']]),
+ idByPath: new Map([
+ ['/Reports', 'fold_1'],
+ ['/Fixtures', 'fold_1'],
+ ]),
+ })
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callList(`workspaceId=${WS}`)
+
+ expect(res.status).toBe(404)
+ expect((await res.json()).error.code).toBe('NOT_FOUND')
+ expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled()
+ })
+
+ it('400s when workspaceId is missing', async () => {
+ const res = await callList('limit=10')
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled()
+ })
+
+ it('400s on a scope outside the enum', async () => {
+ const res = await callList(`workspaceId=${WS}&scope=everything`)
+ expect(res.status).toBe(400)
+ expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue({
+ status: 403,
+ code: 'FORBIDDEN',
+ message: 'Access denied',
+ })
+ const res = await callList(`workspaceId=${WS}`)
+ expect(res.status).toBe(403)
+ expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callList(`workspaceId=${WS}`)
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('returns the public file shape including folder and updatedAt', async () => {
+ mockQueryWorkspaceFiles.mockResolvedValue({
+ files: [buildRecord({ folderId: FOLDER_ID, folderPath: 'Reports/Q1' })],
+ nextKeys: null,
+ })
+
+ const res = await callList(`workspaceId=${WS}`)
+ const body = await res.json()
+
+ expect(res.status).toBe(200)
+ expect(body.nextCursor).toBeNull()
+ expect(body.data).toEqual([
+ {
+ id: 'wf_1',
+ name: 'data.csv',
+ size: 1024,
+ type: 'text/csv',
+ key: 'workspace/ws/1-x-data.csv',
+ folderPath: '/Reports/Q1',
+ uploadedBy: 'user-1',
+ uploadedAt: '2024-01-01T00:00:00.000Z',
+ updatedAt: '2024-01-02T00:00:00.000Z',
+ },
+ ])
+ expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, DEFAULT_LIST_ARGS)
+ })
+
+ it('lists active files only and rejects the removed archived scope', async () => {
+ await callList(`workspaceId=${WS}`)
+ expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, DEFAULT_LIST_ARGS)
+
+ const res = await callList(`workspaceId=${WS}&scope=archived`)
+ expect(res.status).toBe(400)
+ })
+
+ it('forwards search, folder, and sort into the query rather than filtering the result', async () => {
+ await callList(
+ `workspaceId=${WS}&search=report&folderPath=${encodeURIComponent('/Reports')}&sortBy=name&sortOrder=desc`
+ )
+
+ expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, {
+ ...DEFAULT_LIST_ARGS,
+ folderId: FOLDER_ID,
+ search: 'report',
+ sortBy: 'name',
+ sortOrder: 'desc',
+ })
+ })
+
+ it('treats folderPath=/ as root-only while omission lists every folder', async () => {
+ await callList(`workspaceId=${WS}&folderPath=%2F`)
+
+ expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, {
+ ...DEFAULT_LIST_ARGS,
+ folderId: null,
+ })
+ })
+
+ it('400s on a sort field outside the enum instead of passing it toward the query', async () => {
+ const res = await callList(`workspaceId=${WS}&sortBy=name;DROP TABLE workspace_files`)
+
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled()
+ })
+
+ it('400s on an empty search rather than treating it as unsearched', async () => {
+ const res = await callList(`workspaceId=${WS}&search=`)
+
+ expect(res.status).toBe(400)
+ expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled()
+ })
+
+ it('emits a cursor stamped with the sort and resumes from its keys', async () => {
+ mockQueryWorkspaceFiles.mockResolvedValue({
+ files: [buildRecord()],
+ nextKeys: ['data.csv', 'wf_1'],
+ })
+
+ const first = await callList(`workspaceId=${WS}&sortBy=name`)
+ const { nextCursor } = await first.json()
+ expect(nextCursor).not.toBeNull()
+
+ await callList(`workspaceId=${WS}&sortBy=name&cursor=${encodeURIComponent(nextCursor)}`)
+
+ expect(mockQueryWorkspaceFiles).toHaveBeenLastCalledWith(WS, {
+ ...DEFAULT_LIST_ARGS,
+ sortBy: 'name',
+ after: ['data.csv', 'wf_1'],
+ })
+ })
+
+ it('400s when a cursor is replayed under a different sort', async () => {
+ mockQueryWorkspaceFiles.mockResolvedValue({
+ files: [buildRecord()],
+ nextKeys: ['data.csv', 'wf_1'],
+ })
+
+ const first = await callList(`workspaceId=${WS}&sortBy=name`)
+ const { nextCursor } = await first.json()
+ mockQueryWorkspaceFiles.mockClear()
+
+ const res = await callList(
+ `workspaceId=${WS}&sortBy=size&cursor=${encodeURIComponent(nextCursor)}`
+ )
+
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.message).toMatch(/cursor does not match/i)
+ expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled()
+ })
+
+ it('400s on a malformed cursor instead of silently restarting from page one', async () => {
+ const res = await callList(`workspaceId=${WS}&cursor=not-a-cursor`)
+
+ expect(res.status).toBe(400)
+ expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled()
+ })
+
+ it('400s when the cursor carries values the sort cannot hold', async () => {
+ mockQueryWorkspaceFiles.mockRejectedValue(
+ new OrchestrationError('validation', 'cursor does not match the requested sortBy/sortOrder.')
+ )
+ const cursor = Buffer.from(
+ JSON.stringify({ sort: 'uploadedAt:asc', keys: ['not-a-date', 'wf_1'] })
+ ).toString('base64')
+
+ const res = await callList(`workspaceId=${WS}&cursor=${encodeURIComponent(cursor)}`)
+
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ })
+
+ it('terminates pagination when the query reports no further keys', async () => {
+ mockQueryWorkspaceFiles.mockResolvedValue({ files: [buildRecord()], nextKeys: null })
+
+ const res = await callList(`workspaceId=${WS}&search=data`)
+
+ expect((await res.json()).nextCursor).toBeNull()
+ })
+})
+
+describe('POST /api/v2/files', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockV2ApiGateError.mockResolvedValue(null)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockPerformCreateWorkspaceFile.mockResolvedValue({
+ success: true,
+ file: buildRecord({ name: 'untitled.md', size: 0, type: 'text/markdown' }),
+ })
+ })
+
+ it('creates an empty exact-name file with an inferred MIME type', async () => {
+ const request = createRequest({ workspaceId: WS, name: 'untitled.md' })
+
+ const response = await POST(request)
+
+ expect(response.status).toBe(201)
+ await expect(response.json()).resolves.toMatchObject({
+ data: { id: 'wf_1', name: 'untitled.md', size: 0, type: 'text/markdown' },
+ })
+ expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(RATE_LIMIT_OK, 'user-1', WS, 'write')
+ expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith({
+ workspaceId: WS,
+ userId: 'user-1',
+ name: 'untitled.md',
+ contentType: 'text/markdown',
+ folderPath: '/',
+ content: Buffer.alloc(0),
+ exactName: true,
+ request,
+ })
+ })
+
+ it('decodes initialized base64 content before orchestration', async () => {
+ mockPerformCreateWorkspaceFile.mockResolvedValue({
+ success: true,
+ file: buildRecord({
+ name: 'seed.bin',
+ size: 3,
+ type: 'application/octet-stream',
+ folderId: FOLDER_ID,
+ folderPath: 'Fixtures',
+ }),
+ })
+ const request = createRequest({
+ workspaceId: WS,
+ name: 'seed.bin',
+ contentType: 'application/octet-stream',
+ folderPath: '/Fixtures',
+ content: Buffer.from([1, 2, 3]).toString('base64'),
+ encoding: 'base64',
+ })
+
+ const response = await POST(request)
+
+ expect(response.status).toBe(201)
+ expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workspaceId: WS,
+ name: 'seed.bin',
+ contentType: 'application/octet-stream',
+ folderPath: '/Fixtures',
+ content: Buffer.from([1, 2, 3]),
+ exactName: true,
+ })
+ )
+ })
+
+ it('rejects malformed base64 before workspace access or orchestration', async () => {
+ const response = await POST(
+ createRequest({
+ workspaceId: WS,
+ name: 'seed.bin',
+ content: 'not-base64!',
+ encoding: 'base64',
+ })
+ )
+
+ expect(response.status).toBe(400)
+ await expect(response.json()).resolves.toMatchObject({
+ error: { code: 'BAD_REQUEST' },
+ })
+ expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled()
+ expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled()
+ })
+
+ it('accepts empty base64 as a zero-byte file', async () => {
+ const response = await POST(
+ createRequest({ workspaceId: WS, name: 'empty.bin', content: '', encoding: 'base64' })
+ )
+
+ expect(response.status).toBe(201)
+ expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith(
+ expect.objectContaining({ content: Buffer.alloc(0) })
+ )
+ })
+
+ it('returns the canonical v2 envelope when the JSON body exceeds the inline limit', async () => {
+ const request = new NextRequest('http://localhost:3000/api/v2/files', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Content-Length': String(MAX_WORKSPACE_FILE_INLINE_BODY_BYTES + 1),
+ },
+ body: '{}',
+ })
+
+ const response = await POST(request)
+
+ expect(response.status).toBe(413)
+ await expect(response.json()).resolves.toMatchObject({
+ error: { code: 'PAYLOAD_TOO_LARGE', message: 'Request body is too large' },
+ })
+ expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled()
+ expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled()
+ })
+
+ it('returns the canonical v2 envelope for malformed JSON', async () => {
+ const request = new NextRequest('http://localhost:3000/api/v2/files', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: '{not-json',
+ })
+
+ const response = await POST(request)
+
+ expect(response.status).toBe(400)
+ await expect(response.json()).resolves.toMatchObject({
+ error: { code: 'BAD_REQUEST', message: 'Request body must be valid JSON' },
+ })
+ expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled()
+ expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ {
+ label: 'name conflict',
+ result: {
+ success: false,
+ error: 'A file with this name already exists',
+ errorCode: 'conflict',
+ },
+ status: 409,
+ code: 'CONFLICT',
+ message: 'A file with this name already exists',
+ },
+ {
+ label: 'internal orchestration failure',
+ result: { success: false, error: 'database connection details', errorCode: 'internal' },
+ status: 500,
+ code: 'INTERNAL_ERROR',
+ message: 'Internal server error',
+ },
+ ])('maps a $label into the v2 error envelope', async ({ result, status, code, message }) => {
+ mockPerformCreateWorkspaceFile.mockResolvedValue(result)
+
+ const response = await POST(createRequest({ workspaceId: WS, name: 'untitled.md' }))
+
+ expect(response.status).toBe(status)
+ await expect(response.json()).resolves.toMatchObject({ error: { code, message } })
+ })
+
+ it('returns the auth failure before gating, access checks, or orchestration', async () => {
+ mockCheckRateLimit.mockResolvedValue({
+ allowed: false,
+ error: 'Invalid API key',
+ limit: 100,
+ remaining: 0,
+ resetAt: RATE_LIMIT_OK.resetAt,
+ })
+
+ const response = await POST(createRequest({ workspaceId: WS, name: 'untitled.md' }))
+
+ expect(response.status).toBe(401)
+ await expect(response.json()).resolves.toMatchObject({
+ error: { code: 'UNAUTHORIZED', message: 'Invalid API key' },
+ })
+ expect(mockV2ApiGateError).not.toHaveBeenCalled()
+ expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled()
+ expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled()
+ })
+
+ it('returns the v2 gate failure before access checks or orchestration', async () => {
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ mockV2ApiGateError.mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const response = await POST(createRequest({ workspaceId: WS, name: 'untitled.md' }))
+
+ expect(response.status).toBe(404)
+ expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled()
+ expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled()
+ })
+
+ it('requires workspace write access before orchestration', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue({
+ status: 403,
+ code: 'FORBIDDEN',
+ message: 'Access denied',
+ })
+
+ const response = await POST(createRequest({ workspaceId: WS, name: 'untitled.md' }))
+
+ expect(response.status).toBe(403)
+ expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(RATE_LIMIT_OK, 'user-1', WS, 'write')
+ expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts
new file mode 100644
index 00000000000..09ba08501d3
--- /dev/null
+++ b/apps/sim/app/api/v2/files/route.ts
@@ -0,0 +1,162 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import {
+ type V2File,
+ v2CreateFileContract,
+ v2ListFilesContract,
+} from '@/lib/api/contracts/v2/files'
+import { parseRequest } from '@/lib/api/server'
+import { messageForOrchestrationError } from '@/lib/core/orchestration/types'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { loadActiveFolderPathIndex } from '@/lib/folders/queries'
+import { queryWorkspaceFiles } from '@/lib/uploads/contexts/workspace'
+import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
+import {
+ MAX_WORKSPACE_FILE_INLINE_BODY_BYTES,
+ performCreateWorkspaceFile,
+} from '@/lib/workspace-files/orchestration'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { toV2File } from '@/app/api/v2/files/utils'
+import { resolveFolderPathId } from '@/app/api/v2/lib/folders'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ cursorSortKey,
+ decodeSortedCursor,
+ encodeSortedCursor,
+ v2CaughtOrchestrationError,
+ v2CursorList,
+ v2CursorSortError,
+ v2Data,
+ v2Error,
+ v2ErrorForOrchestration,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2FilesAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+/**
+ * GET /api/v2/files — List files in a workspace with search, sort, and cursor
+ * pagination.
+ *
+ * Filtering, ordering, and the page slice all run inside
+ * {@link queryWorkspaceFiles}' query. The route only translates the validated
+ * params and the opaque cursor, so a `search` never costs a full-workspace read.
+ */
+export const GET = withRouteHandler(async (request: NextRequest) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'files')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2ListFilesContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ const { workspaceId, folderPath, search, sortBy, sortOrder, limit, cursor } = parsed.data.query
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'file')
+ const folderId =
+ folderPath === undefined ? undefined : resolveFolderPathId(folderIndex, folderPath)
+ if (folderPath !== undefined && folderId === undefined) {
+ return v2Error('NOT_FOUND', 'Folder not found')
+ }
+
+ const sort = cursorSortKey(sortBy, sortOrder)
+ const decoded = decodeSortedCursor(cursor, sort)
+ if (decoded.status === 'invalid') return v2CursorSortError()
+
+ const { files, nextKeys } = await queryWorkspaceFiles(workspaceId, {
+ folderId,
+ search,
+ sortBy,
+ sortOrder,
+ limit,
+ after: decoded.status === 'ok' ? decoded.keys : undefined,
+ })
+
+ const items: V2File[] = files.map(toV2File)
+ const nextCursor = nextKeys ? encodeSortedCursor(sort, nextKeys) : null
+
+ return v2CursorList(items, nextCursor, { rateLimit })
+ } catch (error) {
+ // A cursor that doesn't fit the requested sort arrives classified as `validation` → 400.
+ const classified = v2CaughtOrchestrationError(error)
+ if (classified) return classified
+
+ logger.error('Error listing files', { error: getErrorMessage(error, 'Unknown error') })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/** POST /api/v2/files — Create an authored workspace file, optionally with initial content. */
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'files')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2CreateFileContract,
+ request,
+ {},
+ {
+ invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'),
+ maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES,
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) {
+ return parsed.response.status === 413
+ ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large')
+ : parsed.response
+ }
+
+ const { workspaceId, name, contentType, folderPath, content, encoding } = parsed.data.body
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const result = await performCreateWorkspaceFile({
+ workspaceId,
+ userId,
+ name,
+ contentType: contentType ?? getMimeTypeFromExtension(getFileExtension(name)),
+ folderPath: folderPath ?? '/',
+ content: Buffer.from(content, encoding),
+ exactName: true,
+ request,
+ })
+ if (!result.success || !result.file) {
+ return v2ErrorForOrchestration(
+ result.errorCode,
+ messageForOrchestrationError(result, 'Failed to create file')
+ )
+ }
+
+ return v2Data(toV2File(result.file), { rateLimit, status: 201 })
+ } catch (error) {
+ logger.error('Error creating file', { error: getErrorMessage(error, 'Unknown error') })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts
new file mode 100644
index 00000000000..1378f35502d
--- /dev/null
+++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts
@@ -0,0 +1,70 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { v2CompleteFileUploadContract } from '@/lib/api/contracts/v2/files'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { completeUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service'
+import { finalizeWorkspaceFileUpload } from '@/app/api/files/uploads/finalizers'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CaughtOrchestrationError,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2CompleteFileUploadAPI')
+
+interface FileUploadRouteParams {
+ params: Promise<{ uploadId: string }>
+}
+
+export const POST = withRouteHandler(
+ async (request: NextRequest, context: FileUploadRouteParams) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'files')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(v2CompleteFileUploadContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+ const { uploadId } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+ const session = await getOwnedUploadSession({
+ uploadId,
+ workspaceId,
+ userId,
+ purpose: 'workspace_file',
+ uploadToken: parsed.data.headers['upload-token'],
+ })
+ const result = await completeUploadSession({
+ session,
+ finalize: async (claimed) => {
+ const finalized = await finalizeWorkspaceFileUpload({
+ session: claimed,
+ actor: { id: userId },
+ request,
+ source: 'api',
+ })
+ return { value: finalized.file, completedFileId: finalized.file.id }
+ },
+ })
+ return v2Data(toV2FileUpload(result.session, result.value), { rateLimit })
+ } catch (error) {
+ const classified = v2CaughtOrchestrationError(error)
+ if (classified) return classified
+ logger.error('Failed to complete file upload', { error: getErrorMessage(error) })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts
new file mode 100644
index 00000000000..bec4c2dc8fa
--- /dev/null
+++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts
@@ -0,0 +1,61 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { v2CreateFileUploadPartUrlsContract } from '@/lib/api/contracts/v2/files'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { createUploadPartUrls, getOwnedUploadSession } from '@/lib/uploads/upload-session/service'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CaughtOrchestrationError,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2FileUploadPartsAPI')
+
+interface FileUploadRouteParams {
+ params: Promise<{ uploadId: string }>
+}
+
+export const POST = withRouteHandler(
+ async (request: NextRequest, context: FileUploadRouteParams) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'files')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(v2CreateFileUploadPartUrlsContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+ const { uploadId } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+ const session = await getOwnedUploadSession({
+ uploadId,
+ workspaceId,
+ userId,
+ purpose: 'workspace_file',
+ uploadToken: parsed.data.headers['upload-token'],
+ })
+ const parts = await createUploadPartUrls({
+ session,
+ partNumbers: parsed.data.body.partNumbers,
+ localOrigin: request.nextUrl.origin,
+ })
+ return v2Data({ parts }, { rateLimit })
+ } catch (error) {
+ const classified = v2CaughtOrchestrationError(error)
+ if (classified) return classified
+ logger.error('Failed to create file upload part URLs', { error: getErrorMessage(error) })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts
new file mode 100644
index 00000000000..3ab1354f063
--- /dev/null
+++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts
@@ -0,0 +1,58 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { v2AbortFileUploadContract } from '@/lib/api/contracts/v2/files'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { abortUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CaughtOrchestrationError,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2FileUploadAPI')
+
+interface FileUploadRouteParams {
+ params: Promise<{ uploadId: string }>
+}
+
+export const DELETE = withRouteHandler(
+ async (request: NextRequest, context: FileUploadRouteParams) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'files')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(v2AbortFileUploadContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+ const { uploadId } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+ const session = await getOwnedUploadSession({
+ uploadId,
+ workspaceId,
+ userId,
+ purpose: 'workspace_file',
+ uploadToken: parsed.data.headers['upload-token'],
+ })
+ const aborted = await abortUploadSession(session)
+ return v2Data(toV2FileUpload(aborted, null), { rateLimit })
+ } catch (error) {
+ const classified = v2CaughtOrchestrationError(error)
+ if (classified) return classified
+ logger.error('Failed to abort file upload session', { error: getErrorMessage(error) })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/files/uploads/route.test.ts b/apps/sim/app/api/v2/files/uploads/route.test.ts
new file mode 100644
index 00000000000..884dfbe87ce
--- /dev/null
+++ b/apps/sim/app/api/v2/files/uploads/route.test.ts
@@ -0,0 +1,212 @@
+/**
+ * @vitest-environment node
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockResolveWorkspaceAccess,
+ mockCreateUploadSession,
+ mockLoadActiveFolderPathIndex,
+ mockWithFolderTreeLock,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceAccess: vi.fn(),
+ mockCreateUploadSession: vi.fn(),
+ mockLoadActiveFolderPathIndex: vi.fn(),
+ mockWithFolderTreeLock: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceAccess: mockResolveWorkspaceAccess,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+vi.mock('@/lib/folders/queries', () => ({
+ loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex,
+}))
+
+vi.mock('@/lib/folders/locks', () => ({
+ withFolderTreeLock: mockWithFolderTreeLock,
+}))
+
+vi.mock('@/lib/uploads/upload-session/service', () => ({
+ createUploadSession: mockCreateUploadSession,
+}))
+
+import { POST } from '@/app/api/v2/files/uploads/route'
+
+const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8'
+const RATE_LIMIT = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2026-08-03T22:00:00.000Z'),
+}
+const UPLOAD_SESSION = {
+ id: 'upload-1',
+ workspaceId: WORKSPACE_ID,
+ userId: 'user-1',
+ knowledgeBaseId: null,
+ workflowId: null,
+ executionId: null,
+ purpose: 'workspace_file',
+ method: 'put',
+ storageContext: 'workspace',
+ storageKey: `${WORKSPACE_ID}/file.csv`,
+ finalKey: `${WORKSPACE_ID}/file.csv`,
+ storageProvider: 's3',
+ providerUploadId: null,
+ providerObjectVersion: null,
+ fileName: 'file.csv',
+ contentType: 'text/csv',
+ fileSize: 10,
+ partSize: null,
+ partCount: null,
+ status: 'uploading',
+ uploadToken: 'signed-upload-token',
+ metadata: {},
+ completedFileId: null,
+ error: null,
+ expiresAt: new Date('2026-08-04T21:00:00.000Z'),
+ createdAt: new Date('2026-08-03T21:00:00.000Z'),
+ updatedAt: new Date('2026-08-03T21:00:00.000Z'),
+ completedAt: null,
+ transfer: {
+ method: 'put',
+ url: 'https://storage.example/upload',
+ headers: { 'content-type': 'text/csv' },
+ },
+}
+
+function request(body: Record) {
+ return POST(
+ new NextRequest('http://localhost:3000/api/v2/files/uploads', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ )
+}
+
+describe('POST /api/v2/files/uploads', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockWithFolderTreeLock.mockImplementation(async (_workspaceId, _resourceType, operation) =>
+ operation({})
+ )
+ mockLoadActiveFolderPathIndex.mockResolvedValue({
+ rowById: new Map(),
+ pathById: new Map(),
+ idByPath: new Map([['/Reports', 'folder-reports']]),
+ })
+ mockCreateUploadSession.mockResolvedValue(UPLOAD_SESSION)
+ })
+
+ it('creates one signed PUT session for a small file', async () => {
+ const response = await request({
+ workspaceId: WORKSPACE_ID,
+ name: 'file.csv',
+ contentType: 'text/csv',
+ size: 10,
+ })
+
+ expect(response.status).toBe(201)
+ const { data } = await response.json()
+ expect(data).toMatchObject({
+ session: { id: 'upload-1', status: 'uploading', file: null },
+ uploadToken: 'signed-upload-token',
+ transfer: { method: 'put', url: 'https://storage.example/upload' },
+ })
+ expect(data.session).not.toHaveProperty('uploadToken')
+ expect(data.session).not.toHaveProperty('transfer')
+ expect(data.session).not.toHaveProperty('partSize')
+ expect(data.session).not.toHaveProperty('partCount')
+ expect(mockCreateUploadSession).toHaveBeenCalledWith({
+ workspaceId: WORKSPACE_ID,
+ userId: 'user-1',
+ purpose: 'workspace_file',
+ fileName: 'file.csv',
+ contentType: 'text/csv',
+ fileSize: 10,
+ metadata: { folderId: null },
+ localOrigin: 'http://localhost:3000',
+ })
+ })
+
+ it('authorizes workspace write access before creating provider state', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue({
+ status: 403,
+ code: 'FORBIDDEN',
+ message: 'Access denied',
+ })
+
+ const response = await request({
+ workspaceId: WORKSPACE_ID,
+ name: 'file.csv',
+ contentType: 'text/csv',
+ size: 10,
+ })
+
+ expect(response.status).toBe(403)
+ expect(mockLoadActiveFolderPathIndex).not.toHaveBeenCalled()
+ expect(mockCreateUploadSession).not.toHaveBeenCalled()
+ })
+
+ it('creates an upload session for an empty workspace file', async () => {
+ const response = await request({
+ workspaceId: WORKSPACE_ID,
+ name: 'file.csv',
+ contentType: 'text/csv',
+ size: 0,
+ })
+
+ expect(response.status).toBe(201)
+ expect(mockCreateUploadSession).toHaveBeenCalledWith(
+ expect.objectContaining({ purpose: 'workspace_file', fileSize: 0 })
+ )
+ })
+
+ it('releases the folder tree lock before creating an upload session', async () => {
+ let lockHeld = false
+ mockWithFolderTreeLock.mockImplementation(async (_workspaceId, _resourceType, operation) => {
+ lockHeld = true
+ try {
+ return await operation({})
+ } finally {
+ lockHeld = false
+ }
+ })
+ mockCreateUploadSession.mockImplementationOnce(async () => {
+ expect(lockHeld).toBe(false)
+ return UPLOAD_SESSION
+ })
+
+ const response = await request({
+ workspaceId: WORKSPACE_ID,
+ name: 'file.csv',
+ contentType: 'text/csv',
+ size: 10,
+ folderPath: '/Reports',
+ })
+
+ expect(response.status).toBe(201)
+ expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith(
+ WORKSPACE_ID,
+ 'file',
+ expect.any(Object)
+ )
+ expect(mockCreateUploadSession).toHaveBeenCalledWith(
+ expect.objectContaining({ metadata: { folderId: 'folder-reports' } })
+ )
+ })
+})
diff --git a/apps/sim/app/api/v2/files/uploads/route.ts b/apps/sim/app/api/v2/files/uploads/route.ts
new file mode 100644
index 00000000000..b57f128b19a
--- /dev/null
+++ b/apps/sim/app/api/v2/files/uploads/route.ts
@@ -0,0 +1,73 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { v2CreateFileUploadContract } from '@/lib/api/contracts/v2/files'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { createUploadSession } from '@/lib/uploads/upload-session/service'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils'
+import { resolveFolderPathIdentity } from '@/app/api/v2/lib/folders'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CaughtOrchestrationError,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2FileUploadsAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'files')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2CreateFileUploadContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+ const { workspaceId, name, contentType, size, folderPath } = parsed.data.body
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+ const resolution = await resolveFolderPathIdentity({
+ workspaceId,
+ resourceType: 'file',
+ path: folderPath ?? '/',
+ })
+ if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found')
+ const session = await createUploadSession({
+ workspaceId,
+ userId,
+ purpose: 'workspace_file',
+ fileName: name,
+ contentType,
+ fileSize: size,
+ metadata: { folderId: resolution.folderId },
+ localOrigin: request.nextUrl.origin,
+ })
+ return v2Data(
+ {
+ session: toV2FileUpload(session, null),
+ uploadToken: session.uploadToken,
+ transfer: session.transfer,
+ },
+ { rateLimit, status: 201 }
+ )
+ } catch (error) {
+ const classified = v2CaughtOrchestrationError(error)
+ if (classified) return classified
+ logger.error('Failed to create file upload session', { error: getErrorMessage(error) })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/files/uploads/utils.ts b/apps/sim/app/api/v2/files/uploads/utils.ts
new file mode 100644
index 00000000000..e90c25c1fda
--- /dev/null
+++ b/apps/sim/app/api/v2/files/uploads/utils.ts
@@ -0,0 +1,37 @@
+import type { V2FileUpload } from '@/lib/api/contracts/v2/files'
+import type { V2UploadStatus } from '@/lib/api/contracts/v2/uploads'
+import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
+import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service'
+import { toV2File } from '@/app/api/v2/files/utils'
+
+export function toV2FileUpload(
+ session: UploadSessionRecord,
+ file: WorkspaceFileRecord | null
+): V2FileUpload {
+ return {
+ id: session.id,
+ status: uploadStatus(session.status),
+ name: session.fileName,
+ contentType: session.contentType,
+ size: session.fileSize,
+ expiresAt: session.expiresAt.toISOString(),
+ error: session.error,
+ file: file ? toV2File(file) : null,
+ }
+}
+
+function uploadStatus(status: string): V2UploadStatus {
+ if (
+ status !== 'uploading' &&
+ status !== 'completing' &&
+ status !== 'finalizing' &&
+ status !== 'completed' &&
+ status !== 'failed' &&
+ status !== 'aborting' &&
+ status !== 'aborted' &&
+ status !== 'expired'
+ ) {
+ throw new Error(`Invalid upload session status: ${status}`)
+ }
+ return status
+}
diff --git a/apps/sim/app/api/v2/files/utils.ts b/apps/sim/app/api/v2/files/utils.ts
new file mode 100644
index 00000000000..f5003680ad4
--- /dev/null
+++ b/apps/sim/app/api/v2/files/utils.ts
@@ -0,0 +1,32 @@
+import type { V2File } from '@/lib/api/contracts/v2/files'
+import { buildFolderPath } from '@/lib/folders/paths'
+import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
+
+/** Shared serialization for the v2 files surface. */
+
+/**
+ * Public file projection. `workspaceId` (already known to the caller, who
+ * supplied it) and the internal storage/versioning columns are not exposed.
+ */
+export function toV2File(record: WorkspaceFileRecord): V2File {
+ const folderPath = record.folderId
+ ? buildFolderPath(
+ (() => {
+ if (!record.folderPath) throw new Error('File references an unresolved folder')
+ return record.folderPath.split('/')
+ })()
+ )
+ : '/'
+
+ return {
+ id: record.id,
+ name: record.name,
+ size: record.size,
+ type: record.type,
+ key: record.key,
+ folderPath,
+ uploadedBy: record.uploadedBy,
+ uploadedAt: record.uploadedAt.toISOString(),
+ updatedAt: record.updatedAt.toISOString(),
+ }
+}
diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts
new file mode 100644
index 00000000000..ef9318b1dc6
--- /dev/null
+++ b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts
@@ -0,0 +1,225 @@
+import { db } from '@sim/db'
+import { document, knowledgeConnector } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { and, eq, isNull } from 'drizzle-orm'
+import { type NextRequest, NextResponse } from 'next/server'
+import {
+ type V2KnowledgeDocument,
+ v2DeleteKnowledgeDocumentContract,
+ v2GetKnowledgeDocumentContract,
+} from '@/lib/api/contracts/v2/knowledge'
+import { parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { performDeleteKnowledgeDocument } from '@/lib/knowledge/orchestration'
+import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types'
+import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils'
+import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2Data,
+ v2Error,
+ v2ErrorForOrchestration,
+ v2RateLimitError,
+ v2ValidationError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2KnowledgeDocumentDetailAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+interface DocumentDetailRouteParams {
+ params: Promise<{ id: string; documentId: string }>
+}
+
+/**
+ * Resolves a knowledge base via the shared v1 ownership invariant
+ * ({@link resolveKnowledgeBase}) and renders any failure in the v2 envelope. A
+ * `404` is always `NOT_FOUND`; a `403` is masked as `NOT_FOUND` on reads and
+ * surfaced as `FORBIDDEN` on writes.
+ */
+async function resolveKnowledgeBaseScoped(
+ id: string,
+ workspaceId: string,
+ userId: string,
+ rateLimit: RateLimitResult,
+ level: 'read' | 'write'
+): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> {
+ const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, level)
+ if (!(result instanceof NextResponse)) return result
+ if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found')
+ return level === 'read'
+ ? v2Error('NOT_FOUND', 'Knowledge base not found')
+ : v2Error('FORBIDDEN', 'Access denied')
+}
+
+/** GET /api/v2/knowledge/[id]/documents/[documentId] — Get document details. */
+export const GET = withRouteHandler(
+ async (request: NextRequest, context: DocumentDetailRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'knowledge-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2GetKnowledgeDocumentContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id: knowledgeBaseId, documentId } = parsed.data.params
+
+ const result = await resolveKnowledgeBaseScoped(
+ knowledgeBaseId,
+ parsed.data.query.workspaceId,
+ userId,
+ rateLimit,
+ 'read'
+ )
+ if (result instanceof NextResponse) return result
+
+ const docs = await db
+ .select({
+ id: document.id,
+ knowledgeBaseId: document.knowledgeBaseId,
+ filename: document.filename,
+ fileSize: document.fileSize,
+ mimeType: document.mimeType,
+ processingStatus: document.processingStatus,
+ processingError: document.processingError,
+ processingStartedAt: document.processingStartedAt,
+ processingCompletedAt: document.processingCompletedAt,
+ chunkCount: document.chunkCount,
+ tokenCount: document.tokenCount,
+ characterCount: document.characterCount,
+ enabled: document.enabled,
+ uploadedAt: document.uploadedAt,
+ connectorId: document.connectorId,
+ connectorType: knowledgeConnector.connectorType,
+ sourceUrl: document.sourceUrl,
+ })
+ .from(document)
+ .leftJoin(knowledgeConnector, eq(document.connectorId, knowledgeConnector.id))
+ .where(
+ and(
+ eq(document.id, documentId),
+ eq(document.knowledgeBaseId, knowledgeBaseId),
+ eq(document.userExcluded, false),
+ isNull(document.archivedAt),
+ isNull(document.deletedAt)
+ )
+ )
+ .limit(1)
+
+ const doc = docs[0]
+ if (!doc) return v2Error('NOT_FOUND', 'Document not found')
+
+ const documentDetail: V2KnowledgeDocument = {
+ id: doc.id,
+ knowledgeBaseId: doc.knowledgeBaseId,
+ filename: doc.filename,
+ fileSize: doc.fileSize,
+ mimeType: doc.mimeType,
+ processingStatus: doc.processingStatus as V2KnowledgeDocument['processingStatus'],
+ processingError: doc.processingError,
+ processingStartedAt: serializeDate(doc.processingStartedAt),
+ processingCompletedAt: serializeDate(doc.processingCompletedAt),
+ chunkCount: doc.chunkCount,
+ tokenCount: doc.tokenCount,
+ characterCount: doc.characterCount,
+ enabled: doc.enabled,
+ connectorId: doc.connectorId,
+ connectorType: doc.connectorType ?? null,
+ sourceUrl: doc.sourceUrl,
+ createdAt: serializeDate(doc.uploadedAt),
+ }
+
+ return v2Data({ document: documentDetail }, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Error getting document`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
+
+/** DELETE /api/v2/knowledge/[id]/documents/[documentId] — Delete a document. */
+export const DELETE = withRouteHandler(
+ async (request: NextRequest, context: DocumentDetailRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'knowledge-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2DeleteKnowledgeDocumentContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id: knowledgeBaseId, documentId } = parsed.data.params
+
+ const result = await resolveKnowledgeBaseScoped(
+ knowledgeBaseId,
+ parsed.data.query.workspaceId,
+ userId,
+ rateLimit,
+ 'write'
+ )
+ if (result instanceof NextResponse) return result
+
+ const docs = await db
+ .select({ id: document.id, filename: document.filename })
+ .from(document)
+ .where(
+ and(
+ eq(document.id, documentId),
+ eq(document.knowledgeBaseId, knowledgeBaseId),
+ eq(document.userExcluded, false),
+ isNull(document.archivedAt),
+ isNull(document.deletedAt)
+ )
+ )
+ .limit(1)
+
+ const doc = docs[0]
+ if (!doc) return v2Error('NOT_FOUND', 'Document not found')
+
+ const outcome = await performDeleteKnowledgeDocument({
+ knowledgeBase: {
+ id: knowledgeBaseId,
+ name: result.kb.name,
+ workspaceId: parsed.data.query.workspaceId,
+ },
+ document: { id: documentId, filename: doc.filename },
+ userId,
+ source: 'api',
+ requestId,
+ request,
+ })
+ if (!outcome.success) {
+ return v2ErrorForOrchestration(outcome.errorCode, outcome.error)
+ }
+
+ return v2Data({ id: documentId, deleted: true as const }, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Error deleting document`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts
new file mode 100644
index 00000000000..b6adfff3c07
--- /dev/null
+++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts
@@ -0,0 +1,295 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { type NextRequest, NextResponse } from 'next/server'
+import {
+ type V2KnowledgeDocumentSummary,
+ v2ListKnowledgeDocumentsContract,
+ v2UploadKnowledgeDocumentContract,
+} from '@/lib/api/contracts/v2/knowledge'
+import { parseRequest } from '@/lib/api/server'
+import {
+ checkAttributedUsageLimits,
+ resolveBillingAttribution,
+ resolveSystemBillingAttribution,
+} from '@/lib/billing/core/billing-attribution'
+import { generateRequestId } from '@/lib/core/utils/request'
+import {
+ isPayloadSizeLimitError,
+ readFileToBufferWithLimit,
+ readFormDataWithLimit,
+} from '@/lib/core/utils/stream-limits'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getDocuments } from '@/lib/knowledge/documents/service'
+import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
+import { performUploadKnowledgeDocument } from '@/lib/knowledge/orchestration'
+import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types'
+import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace'
+import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types'
+import { validateFileType } from '@/lib/uploads/utils/validation'
+import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils'
+import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ decodeCursor,
+ encodeCursor,
+ v2CursorList,
+ v2Data,
+ v2Error,
+ v2ErrorForOrchestration,
+ v2RateLimitError,
+ v2ValidationError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2KnowledgeDocumentsAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+const MAX_FILE_SIZE = MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE
+const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024
+
+interface DocumentsRouteParams {
+ params: Promise<{ id: string }>
+}
+
+/**
+ * Resolves a knowledge base via the shared v1 ownership invariant
+ * ({@link resolveKnowledgeBase}) and renders any failure in the v2 envelope. A
+ * `404` is always `NOT_FOUND`; a `403` is masked as `NOT_FOUND` on reads and
+ * surfaced as `FORBIDDEN` on writes.
+ */
+async function resolveKnowledgeBaseScoped(
+ id: string,
+ workspaceId: string,
+ userId: string,
+ rateLimit: RateLimitResult,
+ level: 'read' | 'write'
+): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> {
+ const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, level)
+ if (!(result instanceof NextResponse)) return result
+ if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found')
+ return level === 'read'
+ ? v2Error('NOT_FOUND', 'Knowledge base not found')
+ : v2Error('FORBIDDEN', 'Access denied')
+}
+
+/** GET /api/v2/knowledge/[id]/documents — List documents in a knowledge base. */
+export const GET = withRouteHandler(async (request: NextRequest, context: DocumentsRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'knowledge-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2ListKnowledgeDocumentsContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { workspaceId, limit, cursor, search, enabledFilter, sortBy, sortOrder } =
+ parsed.data.query
+ const { id: knowledgeBaseId } = parsed.data.params
+
+ const result = await resolveKnowledgeBaseScoped(
+ knowledgeBaseId,
+ workspaceId,
+ userId,
+ rateLimit,
+ 'read'
+ )
+ if (result instanceof NextResponse) return result
+
+ // Opaque cursor encodes the underlying offset (upgradeable to keyset later).
+ const offset = cursor ? (decodeCursor<{ offset: number }>(cursor)?.offset ?? 0) : 0
+
+ const documentsResult = await getDocuments(
+ knowledgeBaseId,
+ {
+ enabledFilter: enabledFilter === 'all' ? undefined : enabledFilter,
+ search,
+ limit,
+ offset,
+ sortBy: sortBy as DocumentSortField,
+ sortOrder: sortOrder as SortOrder,
+ },
+ requestId
+ )
+
+ const documents: V2KnowledgeDocumentSummary[] = documentsResult.documents.map((doc) => ({
+ id: doc.id,
+ knowledgeBaseId,
+ filename: doc.filename,
+ fileSize: doc.fileSize,
+ mimeType: doc.mimeType,
+ processingStatus: doc.processingStatus,
+ chunkCount: doc.chunkCount,
+ tokenCount: doc.tokenCount,
+ characterCount: doc.characterCount,
+ enabled: doc.enabled,
+ createdAt: serializeDate(doc.uploadedAt),
+ }))
+
+ const nextCursor = documentsResult.pagination.hasMore
+ ? encodeCursor({ offset: offset + limit })
+ : null
+ return v2CursorList(documents, nextCursor, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Error listing documents`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/**
+ * POST /api/v2/knowledge/[id]/documents — Upload a document to a knowledge base.
+ *
+ * Authorization runs fully before the multipart body is buffered: the workspace
+ * is a contract-validated query param (not a form field as in v1), so an
+ * unauthorized caller never streams a file into memory. Order: rate limit →
+ * KB ownership (write) → usage gate → buffered multipart read.
+ */
+export const POST = withRouteHandler(
+ async (request: NextRequest, context: DocumentsRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'knowledge-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2UploadKnowledgeDocumentContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id: knowledgeBaseId } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+
+ const result = await resolveKnowledgeBaseScoped(
+ knowledgeBaseId,
+ workspaceId,
+ userId,
+ rateLimit,
+ 'write'
+ )
+ if (result instanceof NextResponse) return result
+
+ /**
+ * Gate before storage and indexing. Workspace keys use the billed account
+ * and immutable payer from one read; personal keys preserve their human actor.
+ */
+ const billingAttribution =
+ rateLimit.keyType === 'workspace'
+ ? await resolveSystemBillingAttribution(workspaceId)
+ : await resolveBillingAttribution({ actorUserId: userId, workspaceId })
+ const usage = await checkAttributedUsageLimits(billingAttribution)
+ if (usage.isExceeded) {
+ return v2Error(
+ 'USAGE_LIMIT_EXCEEDED',
+ usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.'
+ )
+ }
+
+ let formData: FormData
+ try {
+ formData = await readFormDataWithLimit(request, {
+ maxBytes: MAX_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES,
+ label: 'knowledge document upload body',
+ })
+ } catch (error) {
+ if (isPayloadSizeLimitError(error)) {
+ return v2Error('PAYLOAD_TOO_LARGE', error.message)
+ }
+ return v2Error('BAD_REQUEST', 'Request body must be valid multipart form data')
+ }
+
+ const rawFile = formData.get('file')
+ const file = rawFile instanceof File ? rawFile : null
+ if (!file) {
+ return v2Error('BAD_REQUEST', 'file form field is required')
+ }
+
+ if (file.size > MAX_FILE_SIZE) {
+ return v2Error(
+ 'PAYLOAD_TOO_LARGE',
+ `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)`
+ )
+ }
+
+ const fileTypeError = validateFileType(file.name, file.type || '')
+ if (fileTypeError) {
+ return v2Error('UNSUPPORTED_MEDIA_TYPE', fileTypeError.message)
+ }
+
+ const buffer = await readFileToBufferWithLimit(file, {
+ maxBytes: MAX_FILE_SIZE,
+ label: 'knowledge document file',
+ })
+ const contentType = file.type || 'application/octet-stream'
+
+ const uploadedFile = await uploadWorkspaceFile(
+ workspaceId,
+ userId,
+ buffer,
+ file.name,
+ contentType
+ )
+
+ const outcome = await performUploadKnowledgeDocument({
+ knowledgeBase: { id: knowledgeBaseId, name: result.kb.name, workspaceId },
+ document: {
+ filename: file.name,
+ fileUrl: uploadedFile.url,
+ fileSize: file.size,
+ mimeType: contentType,
+ },
+ startProcessing: 'queue',
+ billingAttribution,
+ uploadedBy: billingAttribution.actorUserId,
+ userId,
+ source: 'api',
+ requestId,
+ request,
+ })
+ if (!outcome.success) {
+ return v2ErrorForOrchestration(outcome.errorCode, outcome.error)
+ }
+ const newDocument = outcome.document
+
+ const document: V2KnowledgeDocumentSummary = {
+ id: newDocument.id,
+ knowledgeBaseId,
+ filename: newDocument.filename,
+ fileSize: newDocument.fileSize,
+ mimeType: newDocument.mimeType,
+ processingStatus: 'pending',
+ chunkCount: 0,
+ tokenCount: 0,
+ characterCount: 0,
+ enabled: newDocument.enabled,
+ createdAt: serializeDate(newDocument.uploadedAt),
+ }
+
+ return v2Data({ document }, { rateLimit, status: 201 })
+ } catch (error) {
+ if (isPayloadSizeLimitError(error)) {
+ return v2Error('PAYLOAD_TOO_LARGE', error.message)
+ }
+
+ logger.error(`[${requestId}] Error uploading document`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.test.ts
new file mode 100644
index 00000000000..62a81397f64
--- /dev/null
+++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.test.ts
@@ -0,0 +1,184 @@
+/**
+ * @vitest-environment node
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockCompleteUploadSession,
+ mockFinalizeKnowledgeDocumentUpload,
+ mockResolveKnowledgeDocumentUploadAccess,
+ mockResolveKnowledgeDocumentUploadAttribution,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockCompleteUploadSession: vi.fn(),
+ mockFinalizeKnowledgeDocumentUpload: vi.fn(),
+ mockResolveKnowledgeDocumentUploadAccess: vi.fn(),
+ mockResolveKnowledgeDocumentUploadAttribution: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit }))
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+vi.mock('@/lib/uploads/upload-session/service', () => ({
+ completeUploadSession: mockCompleteUploadSession,
+}))
+vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({
+ finalizeKnowledgeDocumentUpload: mockFinalizeKnowledgeDocumentUpload,
+ getOwnedKnowledgeDocumentUpload: vi.fn(() => SESSION),
+ resolveKnowledgeDocumentUploadAccess: mockResolveKnowledgeDocumentUploadAccess,
+ resolveKnowledgeDocumentUploadAttribution: mockResolveKnowledgeDocumentUploadAttribution,
+ toV2KnowledgeDocumentUpload: (session: Record, document: unknown) => ({
+ ...session,
+ name: session.fileName,
+ contentType: session.contentType,
+ size: session.fileSize,
+ expiresAt: '2026-08-04T21:00:00.000Z',
+ document,
+ }),
+}))
+
+import { OrchestrationError } from '@/lib/core/orchestration/types'
+import { POST } from '@/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route'
+
+const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8'
+const FILE_URL = '/api/files/serve/s3/kb%2Fguide.pdf?context=knowledge-base'
+const SESSION = {
+ id: 'upload-1',
+ workspaceId: WORKSPACE_ID,
+ userId: 'user-1',
+ knowledgeBaseId: 'kb-1',
+ workflowId: null,
+ executionId: null,
+ purpose: 'knowledge_document',
+ method: 'multipart',
+ storageContext: 'knowledge-base',
+ storageKey: 'kb/guide.pdf',
+ finalKey: 'kb/guide.pdf',
+ storageProvider: 's3',
+ providerUploadId: 'provider-1',
+ providerObjectVersion: null,
+ fileName: 'guide.pdf',
+ contentType: 'application/pdf',
+ fileSize: 1024,
+ partSize: 8 * 1024 * 1024,
+ partCount: 1,
+ status: 'uploading',
+ metadata: {
+ tag1: 'product',
+ processingOptions: { recipe: 'default', lang: 'en' },
+ },
+ uploadToken: 'token',
+ createdAt: new Date('2026-08-03T21:00:00.000Z'),
+ expiresAt: new Date('2026-08-04T21:00:00.000Z'),
+ completedFileId: null,
+ error: null,
+ completedAt: null,
+ updatedAt: new Date('2026-08-03T21:00:00.000Z'),
+} as const
+const DOCUMENT = {
+ id: 'upload-1',
+ knowledgeBaseId: 'kb-1',
+ filename: 'guide.pdf',
+ fileUrl: FILE_URL,
+ fileSize: 1024,
+ mimeType: 'application/pdf',
+ chunkCount: 0,
+ tokenCount: 0,
+ characterCount: 0,
+ enabled: true,
+ uploadedAt: new Date('2026-08-03T21:01:00.000Z'),
+}
+const RATE_LIMIT = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2026-08-03T22:00:00.000Z'),
+}
+
+function request() {
+ return POST(
+ new NextRequest(
+ `http://localhost:3000/api/v2/knowledge/kb-1/documents/uploads/upload-1/complete?workspaceId=${WORKSPACE_ID}`,
+ {
+ method: 'POST',
+ headers: { 'upload-token': 'token' },
+ }
+ ),
+ { params: Promise.resolve({ id: 'kb-1', uploadId: 'upload-1' }) }
+ )
+}
+
+describe('POST knowledge-document multipart completion', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT)
+ mockResolveKnowledgeDocumentUploadAccess.mockResolvedValue({
+ kb: { id: 'kb-1', name: 'Docs' },
+ })
+ mockResolveKnowledgeDocumentUploadAttribution.mockResolvedValue({ actorUserId: 'payer-1' })
+ mockFinalizeKnowledgeDocumentUpload.mockResolvedValue({
+ value: DOCUMENT,
+ completedFileId: DOCUMENT.id,
+ })
+ mockCompleteUploadSession.mockImplementation(async ({ session, finalize }) => {
+ const finalized = await finalize(session)
+ return {
+ session: { ...session, status: 'completed', completedFileId: finalized.completedFileId },
+ value: finalized.value,
+ alreadyCompleted: false,
+ }
+ })
+ })
+
+ it('delegates completion to the shared finalizer and returns the bound document', async () => {
+ const response = await request()
+
+ expect(response.status).toBe(200)
+ expect(await response.json()).toMatchObject({ data: { document: { id: 'upload-1' } } })
+ expect(mockFinalizeKnowledgeDocumentUpload).toHaveBeenCalledWith(
+ expect.objectContaining({
+ claimed: SESSION,
+ knowledgeBaseId: 'kb-1',
+ knowledgeBaseName: 'Docs',
+ workspaceId: WORKSPACE_ID,
+ userId: 'user-1',
+ source: 'api',
+ })
+ )
+ expect(mockCompleteUploadSession).toHaveBeenCalledWith(
+ expect.objectContaining({
+ session: SESSION,
+ })
+ )
+ })
+
+ it('resolves the payer lazily, only when the finalizer asks for one', async () => {
+ await request()
+
+ expect(mockResolveKnowledgeDocumentUploadAttribution).not.toHaveBeenCalled()
+
+ const { resolveAttribution } = mockFinalizeKnowledgeDocumentUpload.mock.calls[0][0]
+ await resolveAttribution()
+
+ expect(mockResolveKnowledgeDocumentUploadAttribution).toHaveBeenCalledWith({
+ workspaceId: WORKSPACE_ID,
+ userId: 'user-1',
+ rateLimit: RATE_LIMIT,
+ })
+ })
+
+ it('maps an orchestration failure from the finalizer onto its v2 status', async () => {
+ mockFinalizeKnowledgeDocumentUpload.mockRejectedValue(
+ new OrchestrationError('payload_too_large', 'Storage limit exceeded')
+ )
+
+ const response = await request()
+
+ expect(response.status).toBe(413)
+ })
+})
diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts
new file mode 100644
index 00000000000..92bc7c69b8a
--- /dev/null
+++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts
@@ -0,0 +1,96 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { NextResponse } from 'next/server'
+import { v2CompleteKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge'
+import { parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { completeUploadSession } from '@/lib/uploads/upload-session/service'
+import { checkRateLimit } from '@/app/api/v1/middleware'
+import {
+ finalizeKnowledgeDocumentUpload,
+ getOwnedKnowledgeDocumentUpload,
+ resolveKnowledgeDocumentUploadAccess,
+ resolveKnowledgeDocumentUploadAttribution,
+ toV2KnowledgeDocumentUpload,
+} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CaughtOrchestrationError,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2CompleteKnowledgeDocumentUploadAPI')
+
+interface KnowledgeDocumentUploadRouteParams {
+ params: Promise<{ id: string; uploadId: string }>
+}
+
+export const POST = withRouteHandler(
+ async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'knowledge-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2CompleteKnowledgeDocumentUploadContract,
+ request,
+ context,
+ { validationErrorResponse: v2ValidationError }
+ )
+ if (!parsed.success) return parsed.response
+ const { id: knowledgeBaseId, uploadId } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+
+ const access = await resolveKnowledgeDocumentUploadAccess({
+ knowledgeBaseId,
+ workspaceId,
+ userId,
+ rateLimit,
+ })
+ if (access instanceof NextResponse) return access
+
+ const session = await getOwnedKnowledgeDocumentUpload({
+ knowledgeBaseId,
+ uploadId,
+ workspaceId,
+ userId,
+ uploadToken: parsed.data.headers['upload-token'],
+ })
+ const result = await completeUploadSession({
+ session,
+ finalize: (claimed) =>
+ finalizeKnowledgeDocumentUpload({
+ claimed,
+ knowledgeBaseId,
+ knowledgeBaseName: access.kb.name,
+ workspaceId,
+ userId,
+ resolveAttribution: () =>
+ resolveKnowledgeDocumentUploadAttribution({ workspaceId, userId, rateLimit }),
+ source: 'api',
+ requestId,
+ request,
+ }),
+ })
+
+ return v2Data(toV2KnowledgeDocumentUpload(result.session, result.value), { rateLimit })
+ } catch (error) {
+ const classified = v2CaughtOrchestrationError(error)
+ if (classified) return classified
+ logger.error(`[${requestId}] Failed to complete knowledge-document upload`, {
+ error: getErrorMessage(error),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts
new file mode 100644
index 00000000000..2bf941b0eb0
--- /dev/null
+++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts
@@ -0,0 +1,78 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { NextResponse } from 'next/server'
+import { v2CreateKnowledgeDocumentUploadPartUrlsContract } from '@/lib/api/contracts/v2/knowledge'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { createUploadPartUrls } from '@/lib/uploads/upload-session/service'
+import { checkRateLimit } from '@/app/api/v1/middleware'
+import {
+ getOwnedKnowledgeDocumentUpload,
+ resolveKnowledgeDocumentUploadAccess,
+} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CaughtOrchestrationError,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2KnowledgeDocumentUploadPartsAPI')
+
+interface KnowledgeDocumentUploadRouteParams {
+ params: Promise<{ id: string; uploadId: string }>
+}
+
+export const POST = withRouteHandler(
+ async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'knowledge-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2CreateKnowledgeDocumentUploadPartUrlsContract,
+ request,
+ context,
+ { validationErrorResponse: v2ValidationError }
+ )
+ if (!parsed.success) return parsed.response
+ const { id: knowledgeBaseId, uploadId } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+
+ const access = await resolveKnowledgeDocumentUploadAccess({
+ knowledgeBaseId,
+ workspaceId,
+ userId,
+ rateLimit,
+ })
+ if (access instanceof NextResponse) return access
+
+ const session = await getOwnedKnowledgeDocumentUpload({
+ knowledgeBaseId,
+ uploadId,
+ workspaceId,
+ userId,
+ uploadToken: parsed.data.headers['upload-token'],
+ })
+ const parts = await createUploadPartUrls({
+ session,
+ partNumbers: parsed.data.body.partNumbers,
+ localOrigin: request.nextUrl.origin,
+ })
+ return v2Data({ parts }, { rateLimit })
+ } catch (error) {
+ const classified = v2CaughtOrchestrationError(error)
+ if (classified) return classified
+ logger.error('Failed to create knowledge-document upload part URLs', {
+ error: getErrorMessage(error),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts
new file mode 100644
index 00000000000..9fd10cad84e
--- /dev/null
+++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts
@@ -0,0 +1,72 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { NextResponse } from 'next/server'
+import { v2AbortKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { checkRateLimit } from '@/app/api/v1/middleware'
+import {
+ abortKnowledgeDocumentUpload,
+ getOwnedKnowledgeDocumentUpload,
+ resolveKnowledgeDocumentUploadAccess,
+ toV2KnowledgeDocumentUpload,
+} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CaughtOrchestrationError,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2KnowledgeDocumentUploadAPI')
+
+interface KnowledgeDocumentUploadRouteParams {
+ params: Promise<{ id: string; uploadId: string }>
+}
+
+export const DELETE = withRouteHandler(
+ async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'knowledge-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2AbortKnowledgeDocumentUploadContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+ const { id: knowledgeBaseId, uploadId } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+
+ const access = await resolveKnowledgeDocumentUploadAccess({
+ knowledgeBaseId,
+ workspaceId,
+ userId,
+ rateLimit,
+ })
+ if (access instanceof NextResponse) return access
+
+ const session = await getOwnedKnowledgeDocumentUpload({
+ knowledgeBaseId,
+ uploadId,
+ workspaceId,
+ userId,
+ uploadToken: parsed.data.headers['upload-token'],
+ })
+ const aborted = await abortKnowledgeDocumentUpload(session, knowledgeBaseId)
+ return v2Data(toV2KnowledgeDocumentUpload(aborted, null), { rateLimit })
+ } catch (error) {
+ const classified = v2CaughtOrchestrationError(error)
+ if (classified) return classified
+ logger.error('Failed to abort knowledge-document upload session', {
+ error: getErrorMessage(error),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.test.ts
new file mode 100644
index 00000000000..0d0b0462332
--- /dev/null
+++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.test.ts
@@ -0,0 +1,138 @@
+/**
+ * @vitest-environment node
+ */
+import { NextRequest, NextResponse } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockCreateKnowledgeDocumentUploadSession,
+ mockResolveKnowledgeDocumentUploadAccess,
+ mockResolveKnowledgeDocumentUploadBilling,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockCreateKnowledgeDocumentUploadSession: vi.fn(),
+ mockResolveKnowledgeDocumentUploadAccess: vi.fn(),
+ mockResolveKnowledgeDocumentUploadBilling: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit }))
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({
+ createKnowledgeDocumentUploadSession: mockCreateKnowledgeDocumentUploadSession,
+ resolveKnowledgeDocumentUploadAccess: mockResolveKnowledgeDocumentUploadAccess,
+ resolveKnowledgeDocumentUploadBilling: mockResolveKnowledgeDocumentUploadBilling,
+ toV2KnowledgeDocumentUpload: (session: Record) => ({
+ ...session,
+ name: session.fileName,
+ contentType: session.contentType,
+ size: session.fileSize,
+ expiresAt: '2026-08-04T21:00:00.000Z',
+ document: null,
+ }),
+}))
+
+import { POST } from '@/app/api/v2/knowledge/[id]/documents/uploads/route'
+
+const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8'
+const RATE_LIMIT = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2026-08-03T22:00:00.000Z'),
+}
+
+function request() {
+ return POST(
+ new NextRequest('http://localhost:3000/api/v2/knowledge/kb-1/documents/uploads', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ workspaceId: WORKSPACE_ID,
+ name: 'guide.pdf',
+ contentType: 'application/pdf',
+ size: 1024,
+ tag1: 'product',
+ processingOptions: { recipe: 'default', lang: 'en' },
+ }),
+ }),
+ { params: Promise.resolve({ id: 'kb-1' }) }
+ )
+}
+
+describe('POST /api/v2/knowledge/[id]/documents/uploads', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT)
+ mockResolveKnowledgeDocumentUploadAccess.mockResolvedValue({
+ kb: { id: 'kb-1', name: 'Docs' },
+ })
+ mockResolveKnowledgeDocumentUploadBilling.mockResolvedValue({ actorUserId: 'user-1' })
+ mockCreateKnowledgeDocumentUploadSession.mockResolvedValue({
+ id: 'upload-1',
+ knowledgeBaseId: 'kb-1',
+ status: 'uploading',
+ fileName: 'guide.pdf',
+ contentType: 'application/pdf',
+ fileSize: 1024,
+ uploadToken: 'token',
+ error: null,
+ transfer: {
+ method: 'put',
+ url: 'https://storage.example/upload',
+ headers: { 'content-type': 'application/pdf' },
+ },
+ })
+ })
+
+ it('authorizes the knowledge base and runs usage billing before accepting storage', async () => {
+ const response = await request()
+
+ expect(response.status).toBe(201)
+ expect(mockResolveKnowledgeDocumentUploadAccess).toHaveBeenCalledWith(
+ expect.objectContaining({
+ knowledgeBaseId: 'kb-1',
+ workspaceId: WORKSPACE_ID,
+ userId: 'user-1',
+ })
+ )
+ expect(mockResolveKnowledgeDocumentUploadBilling).toHaveBeenCalled()
+ expect(mockCreateKnowledgeDocumentUploadSession).toHaveBeenCalledWith({
+ workspaceId: WORKSPACE_ID,
+ userId: 'user-1',
+ knowledgeBaseId: 'kb-1',
+ fileName: 'guide.pdf',
+ contentType: 'application/pdf',
+ fileSize: 1024,
+ metadata: {
+ tag1: 'product',
+ processingOptions: { recipe: 'default', lang: 'en' },
+ },
+ localOrigin: 'http://localhost:3000',
+ })
+ expect((await response.json()).data).toMatchObject({
+ session: { id: 'upload-1', status: 'uploading', document: null },
+ uploadToken: 'token',
+ transfer: { method: 'put', url: 'https://storage.example/upload' },
+ })
+ expect(mockResolveKnowledgeDocumentUploadBilling.mock.invocationCallOrder[0]).toBeLessThan(
+ mockCreateKnowledgeDocumentUploadSession.mock.invocationCallOrder[0]
+ )
+ })
+
+ it('does not run billing or create provider state when knowledge write access is denied', async () => {
+ mockResolveKnowledgeDocumentUploadAccess.mockResolvedValue(
+ NextResponse.json({ error: { code: 'FORBIDDEN', message: 'Access denied' } }, { status: 403 })
+ )
+
+ const response = await request()
+
+ expect(response.status).toBe(403)
+ expect(mockResolveKnowledgeDocumentUploadBilling).not.toHaveBeenCalled()
+ expect(mockCreateKnowledgeDocumentUploadSession).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts
new file mode 100644
index 00000000000..e736548fd98
--- /dev/null
+++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts
@@ -0,0 +1,94 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { NextResponse } from 'next/server'
+import { v2CreateKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { validateFileType } from '@/lib/uploads/utils/validation'
+import { checkRateLimit } from '@/app/api/v1/middleware'
+import {
+ createKnowledgeDocumentUploadSession,
+ resolveKnowledgeDocumentUploadAccess,
+ resolveKnowledgeDocumentUploadBilling,
+ toV2KnowledgeDocumentUpload,
+} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CaughtOrchestrationError,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2KnowledgeDocumentUploadsAPI')
+
+interface KnowledgeDocumentUploadsRouteParams {
+ params: Promise<{ id: string }>
+}
+
+export const POST = withRouteHandler(
+ async (request: NextRequest, context: KnowledgeDocumentUploadsRouteParams) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'knowledge-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2CreateKnowledgeDocumentUploadContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+ const { id: knowledgeBaseId } = parsed.data.params
+ const { workspaceId, name, contentType, size, ...metadata } = parsed.data.body
+
+ const access = await resolveKnowledgeDocumentUploadAccess({
+ knowledgeBaseId,
+ workspaceId,
+ userId,
+ rateLimit,
+ })
+ if (access instanceof NextResponse) return access
+
+ const billing = await resolveKnowledgeDocumentUploadBilling({
+ workspaceId,
+ userId,
+ rateLimit,
+ })
+ if (billing instanceof NextResponse) return billing
+
+ const fileTypeError = validateFileType(name, contentType)
+ if (fileTypeError) {
+ return v2Error('UNSUPPORTED_MEDIA_TYPE', fileTypeError.message)
+ }
+
+ const session = await createKnowledgeDocumentUploadSession({
+ workspaceId,
+ userId,
+ knowledgeBaseId,
+ fileName: name,
+ contentType,
+ fileSize: size,
+ metadata,
+ localOrigin: request.nextUrl.origin,
+ })
+ return v2Data(
+ {
+ session: toV2KnowledgeDocumentUpload(session, null),
+ uploadToken: session.uploadToken,
+ transfer: session.transfer,
+ },
+ { rateLimit, status: 201 }
+ )
+ } catch (error) {
+ const classified = v2CaughtOrchestrationError(error)
+ if (classified) return classified
+ logger.error('Failed to create knowledge-document upload session', {
+ error: getErrorMessage(error),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts
new file mode 100644
index 00000000000..d7f25983576
--- /dev/null
+++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts
@@ -0,0 +1,254 @@
+/**
+ * @vitest-environment node
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service'
+
+const {
+ mockAbortUploadSession,
+ mockCreateUploadSession,
+ mockFindBoundKnowledgeDocument,
+ mockPerformUploadKnowledgeDocument,
+ mockRecordKnowledgeBaseFileOwnership,
+} = vi.hoisted(() => ({
+ mockAbortUploadSession: vi.fn(),
+ mockCreateUploadSession: vi.fn(),
+ mockFindBoundKnowledgeDocument: vi.fn(),
+ mockPerformUploadKnowledgeDocument: vi.fn(),
+ mockRecordKnowledgeBaseFileOwnership: vi.fn(),
+}))
+
+vi.mock('@/lib/knowledge/orchestration', () => ({
+ performUploadKnowledgeDocument: mockPerformUploadKnowledgeDocument,
+}))
+vi.mock('@/lib/knowledge/orchestration/documents', () => ({
+ findBoundKnowledgeDocument: mockFindBoundKnowledgeDocument,
+}))
+vi.mock('@/lib/uploads/upload-session/service', () => ({
+ abortUploadSession: mockAbortUploadSession,
+ createUploadSession: mockCreateUploadSession,
+ getOwnedUploadSession: vi.fn(),
+}))
+vi.mock('@/lib/uploads/server/metadata', () => ({
+ recordKnowledgeBaseFileOwnership: mockRecordKnowledgeBaseFileOwnership,
+}))
+
+import {
+ abortKnowledgeDocumentUpload,
+ createKnowledgeDocumentUploadSession,
+ finalizeKnowledgeDocumentUpload,
+ toV2KnowledgeDocumentUpload,
+} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils'
+
+const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8'
+const CLAIMED: UploadSessionRecord = {
+ id: 'upload-1',
+ workspaceId: WORKSPACE_ID,
+ userId: 'user-1',
+ knowledgeBaseId: 'kb-1',
+ workflowId: null,
+ executionId: null,
+ purpose: 'knowledge_document',
+ method: 'multipart',
+ storageContext: 'knowledge-base',
+ storageKey: 'kb/guide.pdf',
+ finalKey: 'kb/guide.pdf',
+ storageProvider: 's3',
+ providerUploadId: 'provider-1',
+ providerObjectVersion: null,
+ fileName: 'guide.pdf',
+ contentType: 'application/pdf',
+ fileSize: 1024,
+ partSize: 8 * 1024 * 1024,
+ partCount: 1,
+ status: 'uploading',
+ metadata: { tag1: 'product', processingOptions: { recipe: 'default', lang: 'en' } },
+ uploadToken: 'token',
+ createdAt: new Date('2026-08-03T21:00:00.000Z'),
+ expiresAt: new Date('2026-08-04T21:00:00.000Z'),
+ completedFileId: null,
+ error: null,
+ completedAt: null,
+ updatedAt: new Date('2026-08-03T21:00:00.000Z'),
+}
+const DOCUMENT = { id: 'upload-1', knowledgeBaseId: 'kb-1', filename: 'guide.pdf' }
+
+function finalize(resolveAttribution = vi.fn().mockResolvedValue({ actorUserId: 'payer-1' })) {
+ return finalizeKnowledgeDocumentUpload({
+ claimed: CLAIMED,
+ knowledgeBaseId: 'kb-1',
+ knowledgeBaseName: 'Docs',
+ workspaceId: WORKSPACE_ID,
+ userId: 'user-1',
+ resolveAttribution,
+ source: 'api',
+ requestId: 'req-1',
+ request: new NextRequest('http://localhost:3000/api/v2/knowledge/kb-1'),
+ })
+}
+
+function createSession() {
+ return createKnowledgeDocumentUploadSession({
+ workspaceId: WORKSPACE_ID,
+ userId: 'user-1',
+ knowledgeBaseId: 'kb-1',
+ fileName: 'guide.pdf',
+ contentType: 'application/pdf',
+ fileSize: 1024,
+ metadata: { tag1: 'product' },
+ localOrigin: 'http://localhost:3000',
+ })
+}
+
+describe('createKnowledgeDocumentUploadSession', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCreateUploadSession.mockResolvedValue(CLAIMED)
+ mockRecordKnowledgeBaseFileOwnership.mockResolvedValue(undefined)
+ mockAbortUploadSession.mockResolvedValue({ ...CLAIMED, status: 'aborted' })
+ })
+
+ it('records the ownership binding before returning the upload token', async () => {
+ await expect(createSession()).resolves.toBe(CLAIMED)
+
+ expect(mockCreateUploadSession).toHaveBeenCalledWith({
+ workspaceId: WORKSPACE_ID,
+ userId: 'user-1',
+ knowledgeBaseId: 'kb-1',
+ purpose: 'knowledge_document',
+ fileName: 'guide.pdf',
+ contentType: 'application/pdf',
+ fileSize: 1024,
+ metadata: { tag1: 'product' },
+ localOrigin: 'http://localhost:3000',
+ })
+ expect(mockRecordKnowledgeBaseFileOwnership).toHaveBeenCalledWith({
+ key: 'kb/guide.pdf',
+ userId: 'user-1',
+ workspaceId: WORKSPACE_ID,
+ originalName: 'guide.pdf',
+ contentType: 'application/pdf',
+ size: 1024,
+ })
+ expect(mockCreateUploadSession.mock.invocationCallOrder[0]).toBeLessThan(
+ mockRecordKnowledgeBaseFileOwnership.mock.invocationCallOrder[0]
+ )
+ })
+
+ it('aborts provider state when the ownership binding cannot be recorded', async () => {
+ mockRecordKnowledgeBaseFileOwnership.mockRejectedValue(new Error('database unavailable'))
+
+ await expect(createSession()).rejects.toThrow('database unavailable')
+ expect(mockAbortUploadSession).toHaveBeenCalledWith(CLAIMED)
+ })
+})
+
+describe('toV2KnowledgeDocumentUpload', () => {
+ it('does not expose reusable upload capabilities after session creation', () => {
+ const serialized = toV2KnowledgeDocumentUpload(CLAIMED, null)
+
+ expect(serialized).not.toHaveProperty('uploadToken')
+ expect(serialized).not.toHaveProperty('partSize')
+ expect(serialized).not.toHaveProperty('partCount')
+ expect(serialized).not.toHaveProperty('transfer')
+ })
+})
+
+describe('abortKnowledgeDocumentUpload', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockAbortUploadSession.mockResolvedValue({ ...CLAIMED, status: 'aborted' })
+ })
+
+ it('aborts an upload that no document is bound to', async () => {
+ mockFindBoundKnowledgeDocument.mockResolvedValue({ status: 'absent' })
+
+ await expect(abortKnowledgeDocumentUpload(CLAIMED, 'kb-1')).resolves.toMatchObject({
+ status: 'aborted',
+ })
+ expect(mockAbortUploadSession).toHaveBeenCalledWith(CLAIMED)
+ })
+
+ it('refuses to abort once a document is bound, so committed bytes survive', async () => {
+ mockFindBoundKnowledgeDocument.mockResolvedValue({ status: 'bound', document: DOCUMENT })
+
+ await expect(abortKnowledgeDocumentUpload(CLAIMED, 'kb-1')).rejects.toThrow(
+ 'Upload has already been completed'
+ )
+ expect(mockAbortUploadSession).not.toHaveBeenCalled()
+ })
+})
+
+describe('finalizeKnowledgeDocumentUpload', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockFindBoundKnowledgeDocument.mockResolvedValue({ status: 'absent' })
+ mockPerformUploadKnowledgeDocument.mockResolvedValue({
+ success: true,
+ document: DOCUMENT,
+ created: true,
+ })
+ })
+
+ it('creates the document, carrying session tags and processing options through', async () => {
+ const result = await finalize()
+
+ expect(result).toEqual({ value: DOCUMENT, completedFileId: 'upload-1' })
+ expect(mockPerformUploadKnowledgeDocument).toHaveBeenCalledWith(
+ expect.objectContaining({
+ documentId: 'upload-1',
+ startProcessing: 'queue',
+ uploadedBy: 'payer-1',
+ processingOptions: { recipe: 'default', lang: 'en' },
+ document: expect.objectContaining({ filename: 'guide.pdf', tag1: 'product' }),
+ })
+ )
+ })
+
+ it('answers a retry from the bound document without resolving a payer', async () => {
+ mockFindBoundKnowledgeDocument.mockResolvedValue({ status: 'bound', document: DOCUMENT })
+ const resolveAttribution = vi.fn()
+
+ const result = await finalize(resolveAttribution)
+
+ expect(result).toEqual({ value: DOCUMENT, completedFileId: 'upload-1' })
+ expect(resolveAttribution).not.toHaveBeenCalled()
+ expect(mockPerformUploadKnowledgeDocument).not.toHaveBeenCalled()
+ })
+
+ it('retains completed bytes for retry when document creation fails', async () => {
+ mockPerformUploadKnowledgeDocument.mockResolvedValue({
+ success: false,
+ errorCode: 'payload_too_large',
+ error: 'Storage limit exceeded',
+ })
+
+ await expect(finalize()).rejects.toThrow('Storage limit exceeded')
+ expect(mockFindBoundKnowledgeDocument).toHaveBeenCalledTimes(1)
+ })
+
+ it('lets a retry converge when the first response fails after the document binds', async () => {
+ mockFindBoundKnowledgeDocument
+ .mockResolvedValueOnce({ status: 'absent' })
+ .mockResolvedValueOnce({ status: 'bound', document: DOCUMENT })
+ mockPerformUploadKnowledgeDocument.mockRejectedValue(new Error('audit sink exploded'))
+
+ await expect(finalize()).rejects.toThrow('audit sink exploded')
+ await expect(finalize()).resolves.toEqual({
+ value: DOCUMENT,
+ completedFileId: 'upload-1',
+ })
+ expect(mockPerformUploadKnowledgeDocument).toHaveBeenCalledTimes(1)
+ })
+
+ it('rejects an upload id already bound to a different document without deleting anything', async () => {
+ mockFindBoundKnowledgeDocument.mockResolvedValue({ status: 'conflict' })
+ const resolveAttribution = vi.fn()
+
+ await expect(finalize(resolveAttribution)).rejects.toThrow(
+ 'Upload id is already bound to a different document'
+ )
+ expect(resolveAttribution).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.ts
new file mode 100644
index 00000000000..4ef4bf2d8fa
--- /dev/null
+++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.ts
@@ -0,0 +1,274 @@
+import type { NextRequest } from 'next/server'
+import { NextResponse } from 'next/server'
+import type {
+ V2KnowledgeDocumentSummary,
+ V2KnowledgeDocumentUpload,
+} from '@/lib/api/contracts/v2/knowledge'
+import { v2KnowledgeDocumentUploadMetadataSchema } from '@/lib/api/contracts/v2/knowledge'
+import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution'
+import {
+ checkAttributedUsageLimits,
+ resolveBillingAttribution,
+ resolveSystemBillingAttribution,
+} from '@/lib/billing/core/billing-attribution'
+import { OrchestrationError } from '@/lib/core/orchestration/types'
+import { performUploadKnowledgeDocument } from '@/lib/knowledge/orchestration'
+import type { CreatedKnowledgeDocument } from '@/lib/knowledge/orchestration/documents'
+import { findBoundKnowledgeDocument } from '@/lib/knowledge/orchestration/documents'
+import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types'
+import { recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata'
+import {
+ abortUploadSession,
+ type CreatedUploadSession,
+ createUploadSession,
+ getOwnedUploadSession,
+ type UploadSessionRecord,
+} from '@/lib/uploads/upload-session/service'
+import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils'
+import type { RateLimitResult } from '@/app/api/v1/middleware'
+import { v2Error } from '@/app/api/v2/lib/response'
+
+export async function resolveKnowledgeDocumentUploadAccess(params: {
+ knowledgeBaseId: string
+ workspaceId: string
+ userId: string
+ rateLimit: RateLimitResult
+}): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> {
+ const result = await resolveKnowledgeBase(
+ params.knowledgeBaseId,
+ params.workspaceId,
+ params.userId,
+ params.rateLimit,
+ 'write'
+ )
+ if (!(result instanceof NextResponse)) return result
+ if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found')
+ return v2Error('FORBIDDEN', 'Access denied')
+}
+
+/**
+ * Resolves the payer for an upload without enforcing usage limits. Completion uses this
+ * because its bytes were already admitted when the session was created; re-running
+ * admission there would strand uploaded parts and fail idempotent completion retries.
+ */
+export async function resolveKnowledgeDocumentUploadAttribution(params: {
+ workspaceId: string
+ userId: string
+ rateLimit: RateLimitResult
+}): Promise {
+ return params.rateLimit.keyType === 'workspace'
+ ? resolveSystemBillingAttribution(params.workspaceId)
+ : resolveBillingAttribution({
+ actorUserId: params.userId,
+ workspaceId: params.workspaceId,
+ })
+}
+
+/** Admission check for a new upload session. Enforced only at session creation. */
+export async function resolveKnowledgeDocumentUploadBilling(params: {
+ workspaceId: string
+ userId: string
+ rateLimit: RateLimitResult
+}): Promise {
+ const attribution = await resolveKnowledgeDocumentUploadAttribution(params)
+ const usage = await checkAttributedUsageLimits(attribution)
+ if (usage.isExceeded) {
+ return v2Error(
+ 'USAGE_LIMIT_EXCEEDED',
+ usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.'
+ )
+ }
+ return attribution
+}
+
+export async function getOwnedKnowledgeDocumentUpload(params: {
+ knowledgeBaseId: string
+ uploadId: string
+ workspaceId: string
+ userId: string
+ uploadToken: string
+}): Promise {
+ return getOwnedUploadSession({
+ uploadId: params.uploadId,
+ workspaceId: params.workspaceId,
+ userId: params.userId,
+ purpose: 'knowledge_document',
+ knowledgeBaseId: params.knowledgeBaseId,
+ uploadToken: params.uploadToken,
+ })
+}
+
+/**
+ * Creates a knowledge-document upload and records its ownership binding before the token is
+ * returned. Failed or abandoned sessions can then be reclaimed by the knowledge-base orphan
+ * sweeper without racing a later document insert.
+ */
+export async function createKnowledgeDocumentUploadSession(params: {
+ workspaceId: string
+ userId: string
+ knowledgeBaseId: string
+ fileName: string
+ contentType: string
+ fileSize: number
+ metadata: Record
+ localOrigin: string
+}): Promise {
+ const session = await createUploadSession({
+ ...params,
+ purpose: 'knowledge_document',
+ })
+ try {
+ await recordKnowledgeBaseFileOwnership({
+ key: session.storageKey,
+ userId: params.userId,
+ workspaceId: params.workspaceId,
+ originalName: params.fileName,
+ contentType: params.contentType,
+ size: params.fileSize,
+ })
+ } catch (error) {
+ await abortUploadSession(session)
+ throw error
+ }
+ return session
+}
+
+export function toV2KnowledgeDocumentSummary(
+ document: CreatedKnowledgeDocument
+): V2KnowledgeDocumentSummary {
+ return {
+ id: document.id,
+ knowledgeBaseId: document.knowledgeBaseId,
+ filename: document.filename,
+ fileSize: document.fileSize,
+ mimeType: document.mimeType,
+ processingStatus: document.processingStatus ?? 'pending',
+ chunkCount: document.chunkCount,
+ tokenCount: document.tokenCount,
+ characterCount: document.characterCount,
+ enabled: document.enabled,
+ createdAt: serializeDate(document.uploadedAt),
+ }
+}
+
+export function toV2KnowledgeDocumentUpload(
+ session: UploadSessionRecord,
+ document: CreatedKnowledgeDocument | null
+): V2KnowledgeDocumentUpload {
+ if (!session.knowledgeBaseId) {
+ throw new Error('Knowledge-document upload session is missing its knowledge base')
+ }
+ return {
+ id: session.id,
+ knowledgeBaseId: session.knowledgeBaseId,
+ status: session.status,
+ name: session.fileName,
+ contentType: session.contentType,
+ size: session.fileSize,
+ expiresAt: session.expiresAt.toISOString(),
+ error: session.error,
+ document: document ? toV2KnowledgeDocumentSummary(document) : null,
+ }
+}
+
+export function knowledgeDocumentFileUrl(session: UploadSessionRecord): string {
+ if (session.storageContext !== 'knowledge-base') {
+ throw new Error('Knowledge-document upload has an invalid storage context')
+ }
+ const providerPrefix = session.storageProvider === 'local' ? '' : `${session.storageProvider}/`
+ return `/api/files/serve/${providerPrefix}${encodeURIComponent(session.storageKey)}?context=knowledge-base`
+}
+
+function knowledgeDocumentInputFor(session: UploadSessionRecord) {
+ const { processingOptions: _processingOptions, ...documentTags } =
+ v2KnowledgeDocumentUploadMetadataSchema.parse(session.metadata)
+ return {
+ filename: session.fileName,
+ fileUrl: knowledgeDocumentFileUrl(session),
+ fileSize: session.fileSize,
+ mimeType: session.contentType,
+ ...documentTags,
+ }
+}
+
+/**
+ * Aborts an upload session, refusing once a document is bound to it.
+ *
+ * The document binding remains the domain-level completion authority while the upload row
+ * protects the provider object lifecycle.
+ */
+export async function abortKnowledgeDocumentUpload(
+ session: UploadSessionRecord,
+ knowledgeBaseId: string
+): Promise {
+ const bound = await findBoundKnowledgeDocument({
+ documentId: session.id,
+ knowledgeBaseId,
+ document: knowledgeDocumentInputFor(session),
+ })
+ if (bound.status !== 'absent') {
+ throw new OrchestrationError('conflict', 'Upload has already been completed')
+ }
+ return abortUploadSession(session)
+}
+
+/**
+ * Binds a completed upload session to its knowledge document. Shared by the public v2
+ * and session-authenticated routes so both get identical completion semantics.
+ *
+ * Ordering is load-bearing. A retry is answered from the already-bound document before any
+ * work that can fail independently of the upload runs, so a payer that became unresolvable
+ * after the session was created cannot turn a valid retry into an error. The ownership binding
+ * is recorded before the upload token is issued, so failures retain retriable state and the
+ * delayed orphan sweeper reclaims sessions that never bind to a document.
+ */
+export async function finalizeKnowledgeDocumentUpload(params: {
+ claimed: UploadSessionRecord
+ knowledgeBaseId: string
+ knowledgeBaseName: string | null
+ workspaceId: string
+ userId: string
+ resolveAttribution: () => Promise
+ source: 'api' | 'ui'
+ requestId: string
+ request: NextRequest
+ actorName?: string | null
+ actorEmail?: string | null
+}): Promise<{ value: CreatedKnowledgeDocument; completedFileId: string }> {
+ const { claimed, knowledgeBaseId, workspaceId, requestId } = params
+ const { processingOptions } = v2KnowledgeDocumentUploadMetadataSchema.parse(claimed.metadata)
+ const document = knowledgeDocumentInputFor(claimed)
+
+ const bound = await findBoundKnowledgeDocument({
+ documentId: claimed.id,
+ knowledgeBaseId,
+ document,
+ })
+ if (bound.status === 'bound') {
+ return { value: bound.document, completedFileId: bound.document.id }
+ }
+ if (bound.status === 'conflict') {
+ throw new OrchestrationError('conflict', 'Upload id is already bound to a different document')
+ }
+
+ const billingAttribution = await params.resolveAttribution()
+ const outcome = await performUploadKnowledgeDocument({
+ knowledgeBase: { id: knowledgeBaseId, name: params.knowledgeBaseName, workspaceId },
+ document,
+ documentId: claimed.id,
+ startProcessing: 'queue',
+ processingOptions,
+ billingAttribution,
+ uploadedBy: billingAttribution.actorUserId,
+ userId: params.userId,
+ ...(params.actorName ? { actorName: params.actorName } : {}),
+ ...(params.actorEmail ? { actorEmail: params.actorEmail } : {}),
+ source: params.source,
+ requestId,
+ request: params.request,
+ })
+ if (!outcome.success) {
+ throw new OrchestrationError(outcome.errorCode, outcome.error)
+ }
+ return { value: outcome.document, completedFileId: outcome.document.id }
+}
diff --git a/apps/sim/app/api/v2/knowledge/[id]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/route.ts
new file mode 100644
index 00000000000..a98d877d330
--- /dev/null
+++ b/apps/sim/app/api/v2/knowledge/[id]/route.ts
@@ -0,0 +1,227 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { type NextRequest, NextResponse } from 'next/server'
+import {
+ v2DeleteKnowledgeBaseContract,
+ v2GetKnowledgeBaseContract,
+ v2UpdateKnowledgeBaseContract,
+} from '@/lib/api/contracts/v2/knowledge'
+import { parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { loadActiveFolderPathIndex } from '@/lib/folders/queries'
+import {
+ performDeleteKnowledgeBase,
+ performUpdateKnowledgeBase,
+} from '@/lib/knowledge/orchestration'
+import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types'
+import { formatKnowledgeBase, resolveKnowledgeBase } from '@/app/api/v1/knowledge/utils'
+import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware'
+import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2Data,
+ v2Error,
+ v2ErrorForOrchestration,
+ v2RateLimitError,
+ v2ValidationError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2KnowledgeDetailAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+interface KnowledgeRouteParams {
+ params: Promise<{ id: string }>
+}
+
+/**
+ * Resolves a knowledge base via the shared v1 ownership invariant
+ * ({@link resolveKnowledgeBase}: workspace access + KB-belongs-to-workspace) and
+ * renders any failure in the v2 envelope. A `404` (missing KB or workspace
+ * mismatch) is always `NOT_FOUND`; a `403` (no workspace access) is masked as
+ * `NOT_FOUND` on reads so cross-workspace KB existence never leaks, and surfaced
+ * as `FORBIDDEN` on writes.
+ */
+async function resolveKnowledgeBaseScoped(
+ id: string,
+ workspaceId: string,
+ userId: string,
+ rateLimit: RateLimitResult,
+ level: 'read' | 'write'
+): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> {
+ const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, level)
+ if (!(result instanceof NextResponse)) return result
+ if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found')
+ return level === 'read'
+ ? v2Error('NOT_FOUND', 'Knowledge base not found')
+ : v2Error('FORBIDDEN', 'Access denied')
+}
+
+/** GET /api/v2/knowledge/[id] — Get knowledge base details. */
+export const GET = withRouteHandler(async (request: NextRequest, context: KnowledgeRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'knowledge-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2GetKnowledgeBaseContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+ const result = await resolveKnowledgeBaseScoped(
+ id,
+ parsed.data.query.workspaceId,
+ userId,
+ rateLimit,
+ 'read'
+ )
+ if (result instanceof NextResponse) return result
+
+ const folderIndex = await loadActiveFolderPathIndex(
+ parsed.data.query.workspaceId,
+ 'knowledge_base'
+ )
+
+ return v2Data(
+ {
+ knowledgeBase: {
+ ...formatKnowledgeBase(result.kb),
+ folderPath: folderPathForId(folderIndex, result.kb.folderId),
+ },
+ },
+ { rateLimit }
+ )
+ } catch (error) {
+ logger.error(`[${requestId}] Error getting knowledge base`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/** PUT /api/v2/knowledge/[id] — Update a knowledge base. */
+export const PUT = withRouteHandler(async (request: NextRequest, context: KnowledgeRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'knowledge-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2UpdateKnowledgeBaseContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+ const { workspaceId, name, description, chunkingConfig, folderPath } = parsed.data.body
+
+ const result = await resolveKnowledgeBaseScoped(id, workspaceId, userId, rateLimit, 'write')
+ if (result instanceof NextResponse) return result
+
+ const resolution =
+ folderPath === undefined
+ ? undefined
+ : await resolveFolderPathIdentity({
+ workspaceId,
+ resourceType: 'knowledge_base',
+ path: folderPath,
+ })
+ if (resolution && !resolution.found) {
+ return v2Error('NOT_FOUND', 'Folder not found')
+ }
+
+ const outcome = await performUpdateKnowledgeBase({
+ knowledgeBaseId: id,
+ workspaceId,
+ userId,
+ source: 'api',
+ updates: { name, description, chunkingConfig, folderId: resolution?.folderId },
+ requestId,
+ request,
+ })
+ if (!outcome.success) {
+ return v2ErrorForOrchestration(outcome.errorCode, outcome.error)
+ }
+
+ const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base')
+ return v2Data(
+ {
+ knowledgeBase: {
+ ...formatKnowledgeBase(outcome.knowledgeBase),
+ folderPath: folderPathForId(folderIndex, outcome.knowledgeBase.folderId),
+ },
+ },
+ { rateLimit }
+ )
+ } catch (error) {
+ logger.error(`[${requestId}] Error updating knowledge base`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/** DELETE /api/v2/knowledge/[id] — Delete a knowledge base. */
+export const DELETE = withRouteHandler(
+ async (request: NextRequest, context: KnowledgeRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'knowledge-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2DeleteKnowledgeBaseContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+ const result = await resolveKnowledgeBaseScoped(
+ id,
+ parsed.data.query.workspaceId,
+ userId,
+ rateLimit,
+ 'write'
+ )
+ if (result instanceof NextResponse) return result
+
+ const outcome = await performDeleteKnowledgeBase({
+ knowledgeBase: { id, name: result.kb.name, workspaceId: parsed.data.query.workspaceId },
+ userId,
+ source: 'api',
+ requestId,
+ request,
+ })
+ if (!outcome.success) {
+ return v2ErrorForOrchestration(outcome.errorCode, outcome.error)
+ }
+
+ return v2Data({ id, deleted: true as const }, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Error deleting knowledge base`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/knowledge/folders/route.ts b/apps/sim/app/api/v2/knowledge/folders/route.ts
new file mode 100644
index 00000000000..4995d5cc486
--- /dev/null
+++ b/apps/sim/app/api/v2/knowledge/folders/route.ts
@@ -0,0 +1,187 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import {
+ v2CreateKnowledgeFolderContract,
+ v2DeleteKnowledgeFolderContract,
+ v2ListKnowledgeFoldersContract,
+ v2RelocateKnowledgeFolderContract,
+} from '@/lib/api/contracts/v2/knowledge'
+import { parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ createFolderAtPath,
+ deleteFolderByPath,
+ relocateFolderByPath,
+} from '@/lib/folders/orchestration'
+import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import {
+ resolveFolderPathId,
+ toV2PathFolder,
+ v2FolderPathMutationError,
+} from '@/app/api/v2/lib/folders'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CursorList,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2KnowledgeFoldersAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+export const GET = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+ try {
+ const rateLimit = await checkRateLimit(request, 'knowledge')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(
+ v2ListKnowledgeFoldersContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+ const { workspaceId, parentPath, search, sortBy, sortOrder } = parsed.data.query
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base')
+ const parentId = parentPath === undefined ? undefined : resolveFolderPathId(index, parentPath)
+ if (parentPath !== undefined && parentId === undefined) {
+ return v2Error('NOT_FOUND', 'Folder not found')
+ }
+ const rows = await listActiveFolderRows(workspaceId, 'knowledge_base', {
+ parentId,
+ search,
+ sortBy,
+ sortOrder,
+ })
+ return v2CursorList(
+ rows.map((row) => toV2PathFolder(row, index, false)),
+ null,
+ { rateLimit }
+ )
+ } catch (error) {
+ logger.error(`[${requestId}] Error listing knowledge folders`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const rateLimit = await checkRateLimit(request, 'knowledge')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(
+ v2CreateKnowledgeFolderContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+ const { workspaceId, path } = parsed.data.body
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+ const result = await createFolderAtPath({
+ resourceType: 'knowledge_base',
+ workspaceId,
+ userId,
+ path,
+ })
+ if (!result.success || !result.folder) {
+ return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder')
+ }
+ const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base')
+ return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit, status: 201 })
+})
+
+export const PATCH = withRouteHandler(async (request: NextRequest) => {
+ const rateLimit = await checkRateLimit(request, 'knowledge')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(
+ v2RelocateKnowledgeFolderContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+ const { workspaceId, path, destinationPath } = parsed.data.body
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+ const result = await relocateFolderByPath({
+ resourceType: 'knowledge_base',
+ workspaceId,
+ userId,
+ path,
+ destinationPath,
+ })
+ if (!result.success || !result.folder) {
+ return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder')
+ }
+ const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base')
+ return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit })
+})
+
+export const DELETE = withRouteHandler(async (request: NextRequest) => {
+ const rateLimit = await checkRateLimit(request, 'knowledge')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(
+ v2DeleteKnowledgeFolderContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+ const { workspaceId, path, recursive } = parsed.data.query
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+ const result = await deleteFolderByPath({
+ resourceType: 'knowledge_base',
+ workspaceId,
+ userId,
+ path,
+ recursive,
+ })
+ if (!result.success || !result.deletedItems) {
+ return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder')
+ }
+ return v2Data(
+ {
+ path,
+ deleted: true as const,
+ deletedItems: {
+ folders: result.deletedItems.folders,
+ knowledgeBases: result.deletedItems.knowledgeBases ?? 0,
+ },
+ },
+ { rateLimit }
+ )
+})
diff --git a/apps/sim/app/api/v2/knowledge/route.test.ts b/apps/sim/app/api/v2/knowledge/route.test.ts
new file mode 100644
index 00000000000..bdd087b441e
--- /dev/null
+++ b/apps/sim/app/api/v2/knowledge/route.test.ts
@@ -0,0 +1,157 @@
+/**
+ * @vitest-environment node
+ *
+ * Public v2 knowledge-base list: the search/filter/sort convention reaching the
+ * lib rather than being applied over its result.
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockResolveWorkspaceAccess,
+ mockGetKnowledgeBases,
+ mockLoadActiveFolderPathIndex,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceAccess: vi.fn(),
+ mockGetKnowledgeBases: vi.fn(),
+ mockLoadActiveFolderPathIndex: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceAccess: mockResolveWorkspaceAccess,
+}))
+
+vi.mock('@/lib/knowledge/service', () => ({
+ getKnowledgeBases: mockGetKnowledgeBases,
+}))
+
+vi.mock('@/lib/folders/queries', () => ({
+ loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex,
+}))
+
+vi.mock('@/lib/knowledge/orchestration', () => ({
+ performCreateKnowledgeBase: vi.fn(),
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+import { GET } from '@/app/api/v2/knowledge/route'
+
+const WS = 'workspace-1'
+const FOLDER_ID = 'fold_1'
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+}
+
+/** What the route forwards for a bare `?workspaceId=` list. */
+const DEFAULT_LIST_ARGS = {
+ folderId: undefined,
+ search: undefined,
+ sortBy: 'createdAt',
+ sortOrder: 'asc',
+}
+
+function buildKnowledgeBase(overrides: Record = {}) {
+ return {
+ id: 'kb_1',
+ userId: 'user-1',
+ name: 'Support docs',
+ description: null,
+ tokenCount: 0,
+ embeddingModel: 'text-embedding-3-small',
+ embeddingDimension: 1536,
+ chunkingConfig: { maxSize: 1024, minSize: 1, overlap: 200 },
+ workspaceId: WS,
+ folderId: null,
+ docCount: 2,
+ createdAt: new Date('2024-01-01T00:00:00Z'),
+ updatedAt: new Date('2024-01-02T00:00:00Z'),
+ deletedAt: null,
+ ...overrides,
+ }
+}
+
+const callList = (query: string) =>
+ GET(new NextRequest(`http://localhost:3000/api/v2/knowledge?${query}`))
+
+describe('GET /api/v2/knowledge', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockGetKnowledgeBases.mockResolvedValue([buildKnowledgeBase()])
+ mockLoadActiveFolderPathIndex.mockResolvedValue({
+ rowById: new Map([['fold_1', { id: 'fold_1', name: 'Support', parentId: null }]]),
+ pathById: new Map([['fold_1', '/Support']]),
+ idByPath: new Map([['/Support', 'fold_1']]),
+ })
+ })
+
+ it('forwards search, folder, and sort into the query rather than filtering the result', async () => {
+ const res = await callList(
+ `workspaceId=${WS}&search=support&folderPath=${encodeURIComponent('/Support')}&sortBy=name&sortOrder=desc`
+ )
+
+ expect(res.status).toBe(200)
+ expect(mockGetKnowledgeBases).toHaveBeenCalledWith('user-1', WS, 'active', {
+ folderId: FOLDER_ID,
+ search: 'support',
+ sortBy: 'name',
+ sortOrder: 'desc',
+ })
+ })
+
+ it('defaults to the createdAt ordering when no sort is requested', async () => {
+ await callList(`workspaceId=${WS}`)
+
+ expect(mockGetKnowledgeBases).toHaveBeenCalledWith('user-1', WS, 'active', DEFAULT_LIST_ARGS)
+ })
+
+ it('treats folderPath=/ as root-only while omission lists every folder', async () => {
+ await callList(`workspaceId=${WS}&folderPath=%2F`)
+
+ expect(mockGetKnowledgeBases).toHaveBeenCalledWith('user-1', WS, 'active', {
+ ...DEFAULT_LIST_ARGS,
+ folderId: null,
+ })
+ })
+
+ it('400s on a sort field outside the enum instead of letting it reach the query', async () => {
+ const res = await callList(`workspaceId=${WS}&sortBy=name);--`)
+
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(mockGetKnowledgeBases).not.toHaveBeenCalled()
+ })
+
+ it('400s on a sort direction outside the enum', async () => {
+ const res = await callList(`workspaceId=${WS}&sortOrder=sideways`)
+
+ expect(res.status).toBe(400)
+ expect(mockGetKnowledgeBases).not.toHaveBeenCalled()
+ })
+
+ it('400s on an empty search rather than treating it as unsearched', async () => {
+ const res = await callList(`workspaceId=${WS}&search=`)
+
+ expect(res.status).toBe(400)
+ expect(mockGetKnowledgeBases).not.toHaveBeenCalled()
+ })
+
+ it('terminates pagination with a filter applied', async () => {
+ const res = await callList(`workspaceId=${WS}&search=support`)
+
+ expect((await res.json()).nextCursor).toBeNull()
+ })
+})
diff --git a/apps/sim/app/api/v2/knowledge/route.ts b/apps/sim/app/api/v2/knowledge/route.ts
new file mode 100644
index 00000000000..12720efaa46
--- /dev/null
+++ b/apps/sim/app/api/v2/knowledge/route.ts
@@ -0,0 +1,158 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import {
+ v2CreateKnowledgeBaseContract,
+ v2ListKnowledgeBasesContract,
+} from '@/lib/api/contracts/v2/knowledge'
+import { parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { loadActiveFolderPathIndex } from '@/lib/folders/queries'
+import { performCreateKnowledgeBase } from '@/lib/knowledge/orchestration'
+import { getKnowledgeBases } from '@/lib/knowledge/service'
+import { formatKnowledgeBase } from '@/app/api/v1/knowledge/utils'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import {
+ folderPathForId,
+ resolveFolderPathId,
+ resolveFolderPathIdentity,
+} from '@/app/api/v2/lib/folders'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CursorList,
+ v2Data,
+ v2Error,
+ v2ErrorForOrchestration,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2KnowledgeAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+/** GET /api/v2/knowledge — List knowledge bases in a workspace. */
+export const GET = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'knowledge')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2ListKnowledgeBasesContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ const { workspaceId, folderPath, search, sortBy, sortOrder } = parsed.data.query
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base')
+ const folderId =
+ folderPath === undefined ? undefined : resolveFolderPathId(folderIndex, folderPath)
+ if (folderPath !== undefined && folderId === undefined) {
+ return v2Error('NOT_FOUND', 'Folder not found')
+ }
+
+ const knowledgeBases = await getKnowledgeBases(userId, workspaceId, 'active', {
+ folderId,
+ search,
+ sortBy,
+ sortOrder,
+ })
+ const items = knowledgeBases.map((knowledgeBase) => ({
+ ...formatKnowledgeBase(knowledgeBase),
+ folderPath: folderPathForId(folderIndex, knowledgeBase.folderId),
+ }))
+
+ // `getKnowledgeBases` returns the full bounded workspace set → single page.
+ return v2CursorList(items, null, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Error listing knowledge bases`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/** POST /api/v2/knowledge — Create a new knowledge base. */
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'knowledge')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2CreateKnowledgeBaseContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ const { workspaceId, name, description, chunkingConfig, folderPath } = parsed.data.body
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const resolution = await resolveFolderPathIdentity({
+ workspaceId,
+ resourceType: 'knowledge_base',
+ path: folderPath ?? '/',
+ })
+ if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found')
+
+ const outcome = await performCreateKnowledgeBase({
+ userId,
+ source: 'api',
+ workspaceId,
+ name,
+ description,
+ chunkingConfig,
+ folderId: resolution.folderId,
+ requestId,
+ request,
+ })
+ if (!outcome.success) {
+ return v2ErrorForOrchestration(outcome.errorCode, outcome.error)
+ }
+
+ return v2Data(
+ {
+ knowledgeBase: {
+ ...formatKnowledgeBase(outcome.knowledgeBase),
+ folderPath: folderPathForId(resolution.index, outcome.knowledgeBase.folderId),
+ },
+ },
+ { rateLimit, status: 201 }
+ )
+ } catch (error) {
+ logger.error(`[${requestId}] Error creating knowledge base`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts
new file mode 100644
index 00000000000..005edb3b919
--- /dev/null
+++ b/apps/sim/app/api/v2/knowledge/search/route.ts
@@ -0,0 +1,319 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import {
+ type V2KnowledgeSearchResult,
+ v2SearchKnowledgeContract,
+} from '@/lib/api/contracts/v2/knowledge'
+import { isZodError, parseRequest } from '@/lib/api/server'
+import {
+ checkAttributedUsageLimits,
+ resolveBillingAttribution,
+ resolveSystemBillingAttribution,
+} from '@/lib/billing/core/billing-attribution'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants'
+import { recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings'
+import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service'
+import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils'
+import type { StructuredFilter } from '@/lib/knowledge/types'
+import {
+ generateSearchEmbedding,
+ getDocumentMetadataByIds,
+ getQueryStrategy,
+ handleTagAndVectorSearch,
+ handleTagOnlySearch,
+ handleVectorOnlySearch,
+ type SearchResult,
+} from '@/app/api/knowledge/search/utils'
+import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2KnowledgeSearchAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+/** POST /api/v2/knowledge/search — Vector / tag search across knowledge bases. */
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'knowledge-search')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2SearchKnowledgeContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ const { workspaceId, topK, query, tagFilters } = parsed.data.body
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
+ if (access) return v2WorkspaceAccessError(access)
+
+ /**
+ * A query incurs hosted embedding (+ optional rerank) cost — gate the
+ * actor's usage before spending; tag-only search is free. Workspace keys
+ * resolve their system actor and immutable payer from one workspace read.
+ */
+ const hasBillableQuery = Boolean(query?.trim())
+ const billingAttribution = hasBillableQuery
+ ? rateLimit.keyType === 'workspace'
+ ? await resolveSystemBillingAttribution(workspaceId)
+ : await resolveBillingAttribution({ actorUserId: userId, workspaceId })
+ : undefined
+ const billingActorUserId = billingAttribution?.actorUserId ?? userId
+ if (billingAttribution) {
+ const usage = await checkAttributedUsageLimits(billingAttribution)
+ if (usage.isExceeded) {
+ return v2Error(
+ 'USAGE_LIMIT_EXCEEDED',
+ usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.'
+ )
+ }
+ }
+
+ const knowledgeBaseIds = Array.isArray(parsed.data.body.knowledgeBaseIds)
+ ? parsed.data.body.knowledgeBaseIds
+ : [parsed.data.body.knowledgeBaseIds]
+
+ const accessChecks = await Promise.all(
+ knowledgeBaseIds.map((kbId) => checkKnowledgeBaseAccess(kbId, userId))
+ )
+ const accessibleKbs = accessChecks
+ .filter(
+ (ac): ac is KnowledgeBaseAccessResult =>
+ ac.hasAccess === true && ac.knowledgeBase.workspaceId === workspaceId
+ )
+ .map((ac) => ac.knowledgeBase)
+ const accessibleKbIds = accessibleKbs.map((kb) => kb.id)
+
+ if (accessibleKbIds.length === 0) {
+ return v2Error('NOT_FOUND', 'Knowledge base not found or access denied')
+ }
+
+ const inaccessibleKbIds = knowledgeBaseIds.filter((id) => !accessibleKbIds.includes(id))
+ if (inaccessibleKbIds.length > 0) {
+ return v2Error(
+ 'NOT_FOUND',
+ `Knowledge bases not found or access denied: ${inaccessibleKbIds.join(', ')}`
+ )
+ }
+
+ let structuredFilters: StructuredFilter[] = []
+ const tagDefsCache = new Map>>()
+
+ if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 1) {
+ return v2Error(
+ 'BAD_REQUEST',
+ 'Tag filters are only supported when searching a single knowledge base'
+ )
+ }
+
+ if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 0) {
+ const kbId = accessibleKbIds[0]
+ const tagDefs = await getDocumentTagDefinitions(kbId)
+ tagDefsCache.set(kbId, tagDefs)
+
+ const displayNameToTagDef: Record = {}
+ tagDefs.forEach((def) => {
+ displayNameToTagDef[def.displayName] = {
+ tagSlot: def.tagSlot,
+ fieldType: def.fieldType,
+ }
+ })
+
+ const undefinedTags: string[] = []
+ const typeErrors: string[] = []
+
+ for (const filter of tagFilters) {
+ const tagDef = displayNameToTagDef[filter.tagName]
+ if (!tagDef) {
+ undefinedTags.push(filter.tagName)
+ continue
+ }
+ const validationError = validateTagValue(
+ filter.tagName,
+ String(filter.value),
+ tagDef.fieldType
+ )
+ if (validationError) {
+ typeErrors.push(validationError)
+ }
+ }
+
+ if (undefinedTags.length > 0 || typeErrors.length > 0) {
+ const errorParts: string[] = []
+ if (undefinedTags.length > 0) {
+ errorParts.push(buildUndefinedTagsError(undefinedTags))
+ }
+ if (typeErrors.length > 0) {
+ errorParts.push(...typeErrors)
+ }
+ return v2Error('BAD_REQUEST', errorParts.join('\n'))
+ }
+
+ structuredFilters = tagFilters.map((filter) => {
+ const tagDef = displayNameToTagDef[filter.tagName]!
+ return {
+ tagSlot: tagDef.tagSlot,
+ fieldType: tagDef.fieldType,
+ operator: filter.operator,
+ value: filter.value,
+ valueTo: filter.valueTo,
+ }
+ })
+ }
+
+ const hasQuery = Boolean(query && query.trim().length > 0)
+ const hasFilters = structuredFilters.length > 0
+
+ const embeddingModels = Array.from(new Set(accessibleKbs.map((kb) => kb.embeddingModel)))
+ if (hasQuery && embeddingModels.length > 1) {
+ return v2Error(
+ 'BAD_REQUEST',
+ 'Selected knowledge bases use different embedding models and cannot be searched together. Search them separately.'
+ )
+ }
+ const queryEmbeddingModel = embeddingModels[0]
+
+ let results: SearchResult[]
+ let queryEmbeddingIsBYOK: boolean | null = null
+
+ if (!hasQuery && hasFilters) {
+ results = await handleTagOnlySearch({
+ knowledgeBaseIds: accessibleKbIds,
+ topK,
+ structuredFilters,
+ })
+ } else if (hasQuery && hasFilters) {
+ const strategy = getQueryStrategy(accessibleKbIds.length, topK)
+ const queryEmbeddingResult = await generateSearchEmbedding(
+ query!,
+ queryEmbeddingModel,
+ workspaceId
+ )
+ queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK
+ const queryVector = JSON.stringify(queryEmbeddingResult.embedding)
+ results = await handleTagAndVectorSearch({
+ knowledgeBaseIds: accessibleKbIds,
+ topK,
+ structuredFilters,
+ queryVector,
+ distanceThreshold: strategy.distanceThreshold,
+ })
+ } else if (hasQuery) {
+ const strategy = getQueryStrategy(accessibleKbIds.length, topK)
+ const queryEmbeddingResult = await generateSearchEmbedding(
+ query!,
+ queryEmbeddingModel,
+ workspaceId
+ )
+ queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK
+ const queryVector = JSON.stringify(queryEmbeddingResult.embedding)
+ results = await handleVectorOnlySearch({
+ knowledgeBaseIds: accessibleKbIds,
+ topK,
+ queryVector,
+ distanceThreshold: strategy.distanceThreshold,
+ })
+ } else {
+ return v2Error('BAD_REQUEST', 'Either query or tagFilters must be provided')
+ }
+
+ if (queryEmbeddingIsBYOK !== null) {
+ await recordSearchEmbeddingUsage({
+ userId: billingActorUserId,
+ workspaceId,
+ embeddingModel: queryEmbeddingModel,
+ query: query!,
+ isBYOK: queryEmbeddingIsBYOK,
+ sourceReference: `v2-kb-search:${requestId}`,
+ billingAttribution,
+ })
+ }
+
+ const tagDefsResults = await Promise.all(
+ accessibleKbIds.map(async (kbId) => {
+ try {
+ const tagDefs = tagDefsCache.get(kbId) ?? (await getDocumentTagDefinitions(kbId))
+ const map: Record = {}
+ tagDefs.forEach((def) => {
+ map[def.tagSlot] = def.displayName
+ })
+ return { kbId, map }
+ } catch {
+ return { kbId, map: {} as Record }
+ }
+ })
+ )
+ const tagDefinitionsMap: Record> = {}
+ tagDefsResults.forEach(({ kbId, map }) => {
+ tagDefinitionsMap[kbId] = map
+ })
+
+ const documentIds = results.map((r) => r.documentId)
+ const documentMetadataMap = await getDocumentMetadataByIds(documentIds)
+
+ const searchResults: V2KnowledgeSearchResult[] = results.map((result) => {
+ const kbTagMap = tagDefinitionsMap[result.knowledgeBaseId] || {}
+ const metadata: Record = {}
+
+ ALL_TAG_SLOTS.forEach((slot) => {
+ const tagValue = result[slot as keyof SearchResult]
+ if (tagValue !== null && tagValue !== undefined) {
+ const displayName = kbTagMap[slot] || slot
+ metadata[displayName] = tagValue
+ }
+ })
+
+ const docMeta = documentMetadataMap[result.documentId]
+ return {
+ documentId: result.documentId,
+ documentName: docMeta?.filename ?? null,
+ sourceUrl: docMeta?.sourceUrl ?? null,
+ content: result.content,
+ chunkIndex: result.chunkIndex,
+ metadata,
+ similarity: hasQuery ? 1 - result.distance : 1,
+ }
+ })
+
+ return v2Data(
+ {
+ results: searchResults,
+ query: query || '',
+ knowledgeBaseIds: accessibleKbIds,
+ topK,
+ totalResults: results.length,
+ },
+ { rateLimit }
+ )
+ } catch (error) {
+ if (isZodError(error)) return v2ValidationError(error)
+ logger.error(`[${requestId}] Knowledge search error`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/lib/folders.ts b/apps/sim/app/api/v2/lib/folders.ts
new file mode 100644
index 00000000000..294bb00771e
--- /dev/null
+++ b/apps/sim/app/api/v2/lib/folders.ts
@@ -0,0 +1,67 @@
+import type { folder } from '@sim/db/schema'
+import type { NextResponse } from 'next/server'
+import type { FolderResourceType } from '@/lib/api/contracts/folders'
+import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types'
+import { withFolderTreeLock } from '@/lib/folders/locks'
+import {
+ type FolderPathIndex,
+ isFolderPathEffectivelyLocked,
+ ROOT_FOLDER_PATH,
+ toFolderPathView,
+} from '@/lib/folders/paths'
+import { loadActiveFolderPathIndex } from '@/lib/folders/queries'
+import { v2ErrorForOrchestration } from '@/app/api/v2/lib/response'
+
+type FolderRow = typeof folder.$inferSelect
+
+export function resolveFolderPathId(
+ index: FolderPathIndex,
+ path: string
+): string | null | undefined {
+ return path === ROOT_FOLDER_PATH ? null : index.idByPath.get(path)
+}
+
+export type ResolvedFolderPathIdentity =
+ | { found: false }
+ | { found: true; folderId: string | null; index: FolderPathIndex }
+
+/** Resolves a path to its stable internal identity under a short-lived folder tree lock. */
+export async function resolveFolderPathIdentity(params: {
+ workspaceId: string
+ resourceType: FolderResourceType
+ path: string
+}): Promise {
+ return withFolderTreeLock(params.workspaceId, params.resourceType, async (tx) => {
+ const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx)
+ const folderId = resolveFolderPathId(index, params.path)
+ return folderId === undefined ? { found: false } : { found: true, folderId, index }
+ })
+}
+
+export function folderPathForId(
+ index: FolderPathIndex,
+ folderId: string | null | undefined
+): string {
+ if (!folderId) return ROOT_FOLDER_PATH
+ const path = index.pathById.get(folderId)
+ if (!path) throw new Error('Resource references an inactive or missing folder')
+ return path
+}
+
+export function toV2PathFolder(
+ row: FolderRow,
+ index: FolderPathIndex,
+ includeLocked: boolean
+) {
+ const path = index.pathById.get(row.id)
+ if (!path) throw new Error('Folder path index is missing a listed folder')
+ const base = toFolderPathView(row, path)
+ return includeLocked ? { ...base, locked: isFolderPathEffectivelyLocked(index, row.id) } : base
+}
+
+export function v2FolderPathMutationError(
+ errorCode: OrchestrationErrorCode | undefined,
+ message: string
+): NextResponse {
+ return v2ErrorForOrchestration(errorCode, message)
+}
diff --git a/apps/sim/app/api/v2/lib/gate.ts b/apps/sim/app/api/v2/lib/gate.ts
new file mode 100644
index 00000000000..d9bf214eece
--- /dev/null
+++ b/apps/sim/app/api/v2/lib/gate.ts
@@ -0,0 +1,23 @@
+import type { NextResponse } from 'next/server'
+import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
+import { v2Error } from '@/app/api/v2/lib/response'
+
+/**
+ * Rollout gate for the entire `/api/v2` surface.
+ *
+ * Exactly one check per request, placed immediately after the route
+ * authenticates and before it does any work. When the flag is off the route
+ * answers 404 as if it did not exist, so an ungated caller cannot distinguish
+ * "not in the rollout cohort" from "no such endpoint".
+ *
+ * Deliberately keyed on `userId` only. A workspace- or org-keyed gate would
+ * have to read membership for a caller-supplied id before authorization has
+ * run, and its 404-vs-403 split would then leak whether that workspace's org
+ * is in the cohort — the trap the per-domain table gate has to work around by
+ * running late. Keyed on the authenticated user, the check is safe to run
+ * first and is uniform across every v2 route.
+ */
+export async function v2ApiGateError(userId: string): Promise {
+ if (await isFeatureEnabled('v2-api', { userId })) return null
+ return v2Error('NOT_FOUND', 'Not found')
+}
diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts
new file mode 100644
index 00000000000..475f73cb5c5
--- /dev/null
+++ b/apps/sim/app/api/v2/lib/response.ts
@@ -0,0 +1,252 @@
+import { NextResponse } from 'next/server'
+import type { ZodError } from 'zod'
+import { type CursorKey, INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query'
+import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server'
+import { asOrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types'
+import type { RateLimitResult, WorkspaceAccessError } from '@/app/api/v1/middleware'
+
+/**
+ * Runtime response helpers for the v2 API surface. Every v2 route renders its
+ * output through these so the envelope, error shape, and rate-limit headers stay
+ * identical across the whole surface. v2 routes reuse the v1 auth/rate-limit
+ * middleware and the platform domain services — these helpers only standardize
+ * the HTTP envelope.
+ */
+
+export type V2ErrorCode =
+ | 'BAD_REQUEST'
+ | 'UNAUTHORIZED'
+ | 'FORBIDDEN'
+ | 'NOT_FOUND'
+ | 'CONFLICT'
+ | 'PAYLOAD_TOO_LARGE'
+ | 'UNSUPPORTED_MEDIA_TYPE'
+ | 'USAGE_LIMIT_EXCEEDED'
+ | 'LOCKED'
+ | 'RATE_LIMITED'
+ | 'CLIENT_CLOSED_REQUEST'
+ | 'INTERNAL_ERROR'
+ | 'SERVICE_UNAVAILABLE'
+
+const STATUS_BY_CODE: Record = {
+ BAD_REQUEST: 400,
+ UNAUTHORIZED: 401,
+ USAGE_LIMIT_EXCEEDED: 402,
+ FORBIDDEN: 403,
+ NOT_FOUND: 404,
+ CONFLICT: 409,
+ PAYLOAD_TOO_LARGE: 413,
+ UNSUPPORTED_MEDIA_TYPE: 415,
+ LOCKED: 423,
+ RATE_LIMITED: 429,
+ CLIENT_CLOSED_REQUEST: 499,
+ INTERNAL_ERROR: 500,
+ SERVICE_UNAVAILABLE: 503,
+}
+
+/**
+ * Every v2 response is authed, per-caller data (ids/filters appear in query
+ * strings) — keep it out of shared HTTP caches unconditionally.
+ */
+const PRIVATE_NO_STORE = { 'Cache-Control': 'private, no-store' } as const
+
+type RateLimitHeaderSource = Pick
+
+export function rateLimitHeaders(rateLimit?: RateLimitHeaderSource): Record {
+ if (!rateLimit) return {}
+ return {
+ 'X-RateLimit-Limit': rateLimit.limit.toString(),
+ 'X-RateLimit-Remaining': rateLimit.remaining.toString(),
+ 'X-RateLimit-Reset': rateLimit.resetAt.toISOString(),
+ }
+}
+
+interface V2SuccessOptions {
+ rateLimit?: RateLimitHeaderSource
+ status?: number
+ headers?: Record
+}
+
+function successHeaders(options: V2SuccessOptions): Record {
+ return { ...PRIVATE_NO_STORE, ...rateLimitHeaders(options.rateLimit), ...options.headers }
+}
+
+/** `{ data }` (+ rate-limit headers). */
+export function v2Data(data: T, options: V2SuccessOptions = {}): NextResponse {
+ return NextResponse.json(
+ { data },
+ { status: options.status ?? 200, headers: successHeaders(options) }
+ )
+}
+
+/** `{ data, nextCursor }` (+ rate-limit headers). */
+export function v2CursorList(
+ data: T[],
+ nextCursor: string | null,
+ options: V2SuccessOptions = {}
+): NextResponse {
+ return NextResponse.json(
+ { data, nextCursor },
+ { status: options.status ?? 200, headers: successHeaders(options) }
+ )
+}
+
+interface V2ErrorOptions {
+ status?: number
+ details?: unknown
+ headers?: Record
+}
+
+/** `{ error: { code, message, details? } }`. */
+export function v2Error(
+ code: V2ErrorCode,
+ message: string,
+ options: V2ErrorOptions = {}
+): NextResponse {
+ const error: { code: V2ErrorCode; message: string; details?: unknown } = { code, message }
+ if (options.details !== undefined) error.details = options.details
+ return NextResponse.json(
+ { error },
+ {
+ status: options.status ?? STATUS_BY_CODE[code],
+ headers: { ...PRIVATE_NO_STORE, ...options.headers },
+ }
+ )
+}
+
+/** Render a contract `ZodError` as the v2 error envelope. */
+export function v2ValidationError(error: ZodError): NextResponse {
+ return v2Error('BAD_REQUEST', getValidationErrorMessage(error, 'Invalid request'), {
+ details: serializeZodIssues(error),
+ })
+}
+
+/** Render a shared {@link WorkspaceAccessError} as the v2 error envelope. */
+export function v2WorkspaceAccessError(failure: WorkspaceAccessError): NextResponse {
+ return v2Error(failure.code, failure.message, { status: failure.status })
+}
+
+/**
+ * Render a v1 rate-limit/auth failure (`checkRateLimit` result) as the v2 error
+ * envelope: an auth failure becomes 401, a throttle becomes 429 with
+ * `Retry-After`.
+ */
+export function v2RateLimitError(rateLimit: RateLimitResult): NextResponse {
+ const headers = rateLimitHeaders(rateLimit)
+ if (rateLimit.error) {
+ return v2Error('UNAUTHORIZED', rateLimit.error, { headers })
+ }
+ const retryAfterSeconds = rateLimit.retryAfterMs
+ ? Math.ceil(rateLimit.retryAfterMs / 1000)
+ : Math.ceil((rateLimit.resetAt.getTime() - Date.now()) / 1000)
+ return v2Error('RATE_LIMITED', 'API rate limit exceeded', {
+ headers: { ...headers, 'Retry-After': retryAfterSeconds.toString() },
+ details: { retryAfter: rateLimit.resetAt.toISOString() },
+ })
+}
+
+/** Opaque base64-JSON keyset cursor codec shared by all v2 cursor lists. */
+export function encodeCursor(data: Record): string {
+ return Buffer.from(JSON.stringify(data)).toString('base64')
+}
+
+export function decodeCursor>(cursor: string): T | null {
+ try {
+ return JSON.parse(Buffer.from(cursor, 'base64').toString()) as T
+ } catch {
+ return null
+ }
+}
+
+/**
+ * The sort a keyset cursor was minted under, as it is written into the cursor
+ * payload. Comparing the whole string is what makes a mid-pagination sort
+ * change detectable.
+ */
+export function cursorSortKey(sortBy: string, sortOrder: string): string {
+ return `${sortBy}:${sortOrder}`
+}
+
+interface SortedCursorPayload {
+ sort: string
+ keys: CursorKey[]
+}
+
+/**
+ * A keyset cursor stamped with the sort that produced it. The keys are only
+ * meaningful under that exact ordering, so the stamp travels with them.
+ */
+export function encodeSortedCursor(sort: string, keys: CursorKey[]): string {
+ return encodeCursor({ sort, keys } satisfies SortedCursorPayload)
+}
+
+export type DecodedSortedCursor =
+ | { status: 'absent' }
+ | { status: 'ok'; keys: CursorKey[] }
+ /** Malformed, or minted under a different sort — the page cannot be resumed. */
+ | { status: 'invalid' }
+
+/**
+ * Reads a keyset cursor back, refusing one that does not belong to the
+ * requested sort. Resuming a `name`-ordered cursor under `createdAt` would
+ * compare the wrong column and silently duplicate or skip rows, so a mismatch
+ * is a client error rather than a best-effort page. A cursor that isn't valid
+ * base64-JSON is rejected for the same reason: ignoring it would restart from
+ * page one while the caller believes it is paging forward.
+ *
+ * This checks the envelope only. The key VALUES are caller-controlled too, and
+ * are type-checked against the sort's keys by `keysetAfter`, which is where a
+ * bad arity or an unparseable timestamp is caught.
+ */
+export function decodeSortedCursor(cursor: string | undefined, sort: string): DecodedSortedCursor {
+ if (!cursor) return { status: 'absent' }
+ const decoded = decodeCursor>(cursor)
+ if (!decoded || decoded.sort !== sort || !Array.isArray(decoded.keys)) {
+ return { status: 'invalid' }
+ }
+ return { status: 'ok', keys: decoded.keys }
+}
+
+/** The 400 for a cursor that cannot be resumed under the request's sort. */
+export function v2CursorSortError(): NextResponse {
+ return v2Error('BAD_REQUEST', INVALID_CURSOR_MESSAGE)
+}
+
+const V2_CODE_BY_ORCHESTRATION_ERROR: Record = {
+ validation: 'BAD_REQUEST',
+ unauthorized: 'UNAUTHORIZED',
+ forbidden: 'FORBIDDEN',
+ not_found: 'NOT_FOUND',
+ conflict: 'CONFLICT',
+ locked: 'LOCKED',
+ payload_too_large: 'PAYLOAD_TOO_LARGE',
+ internal: 'INTERNAL_ERROR',
+}
+
+/**
+ * Renders a `lib/[resource]/orchestration` failure in the v2 envelope, so every
+ * v2 route maps a given failure class to the same status without restating the
+ * mapping. Mirrors `statusForOrchestrationError` for the v1/UI surfaces.
+ */
+export function v2ErrorForOrchestration(
+ code: OrchestrationErrorCode | undefined,
+ message: string,
+ /** Structured context for the failure — e.g. which lock rejected a write. */
+ details?: unknown
+): NextResponse {
+ const v2Code = code ? V2_CODE_BY_ORCHESTRATION_ERROR[code] : 'INTERNAL_ERROR'
+ return v2Error(v2Code, v2Code === 'INTERNAL_ERROR' ? 'Internal server error' : message, {
+ ...(details !== undefined ? { details } : {}),
+ })
+}
+
+/**
+ * Renders a thrown domain failure in the v2 envelope, or `null` when the error
+ * carries no classification and the caller should log it and return its own
+ * generic 500. The v2 counterpart of `orchestrationErrorResponse`.
+ */
+export function v2CaughtOrchestrationError(error: unknown): NextResponse | null {
+ const classified = asOrchestrationError(error)
+ if (!classified) return null
+ return v2ErrorForOrchestration(classified.code, classified.message)
+}
diff --git a/apps/sim/app/api/v2/logs/[id]/route.ts b/apps/sim/app/api/v2/logs/[id]/route.ts
new file mode 100644
index 00000000000..b9307d353f7
--- /dev/null
+++ b/apps/sim/app/api/v2/logs/[id]/route.ts
@@ -0,0 +1,120 @@
+import { db } from '@sim/db'
+import { workflow, workflowExecutionLogs } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { eq } from 'drizzle-orm'
+import type { NextRequest } from 'next/server'
+import { type V2LogDetail, v2GetLogContract } from '@/lib/api/contracts/v2/logs'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { loadActiveFolderPathIndex } from '@/lib/folders/queries'
+import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2LogDetailAPI')
+
+export const revalidate = 0
+
+export const GET = withRouteHandler(
+ async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'logs-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2GetLogContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+
+ const rows = await db
+ .select({
+ id: workflowExecutionLogs.id,
+ workflowId: workflowExecutionLogs.workflowId,
+ workspaceId: workflowExecutionLogs.workspaceId,
+ executionId: workflowExecutionLogs.executionId,
+ level: workflowExecutionLogs.level,
+ trigger: workflowExecutionLogs.trigger,
+ startedAt: workflowExecutionLogs.startedAt,
+ endedAt: workflowExecutionLogs.endedAt,
+ totalDurationMs: workflowExecutionLogs.totalDurationMs,
+ executionData: workflowExecutionLogs.executionData,
+ costTotal: workflowExecutionLogs.costTotal,
+ files: workflowExecutionLogs.files,
+ createdAt: workflowExecutionLogs.createdAt,
+ workflowName: workflow.name,
+ workflowDescription: workflow.description,
+ workflowFolderId: workflow.folderId,
+ workflowUserId: workflow.userId,
+ workflowWorkspaceId: workflow.workspaceId,
+ workflowCreatedAt: workflow.createdAt,
+ workflowUpdatedAt: workflow.updatedAt,
+ workflowArchivedAt: workflow.archivedAt,
+ })
+ .from(workflowExecutionLogs)
+ .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
+ .where(eq(workflowExecutionLogs.id, id))
+ .limit(1)
+
+ const log = rows[0]
+ if (!log) return v2Error('NOT_FOUND', 'Log not found')
+
+ // Convert an authorization failure into 404 so existence is not leaked.
+ const access = await resolveWorkspaceAccess(rateLimit, userId, log.workspaceId)
+ if (access) return v2Error('NOT_FOUND', 'Log not found')
+
+ const folderIndex = await loadActiveFolderPathIndex(log.workspaceId, 'workflow')
+
+ const executionData = await materializeExecutionData(
+ log.executionData as Record | null,
+ { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId }
+ )
+
+ const detail: V2LogDetail = {
+ id: log.id,
+ workflowId: log.workflowId,
+ executionId: log.executionId,
+ level: log.level,
+ trigger: log.trigger,
+ startedAt: log.startedAt.toISOString(),
+ endedAt: log.endedAt ? log.endedAt.toISOString() : null,
+ totalDurationMs: log.totalDurationMs,
+ files: (log.files as unknown[] | null) ?? null,
+ workflow: {
+ id: log.workflowId,
+ name: log.workflowName || 'Deleted Workflow',
+ description: log.workflowDescription,
+ folderPath: log.workflowFolderId
+ ? (folderIndex.pathById.get(log.workflowFolderId) ?? null)
+ : null,
+ userId: log.workflowUserId,
+ workspaceId: log.workflowWorkspaceId,
+ createdAt: log.workflowCreatedAt ? log.workflowCreatedAt.toISOString() : null,
+ updatedAt: log.workflowUpdatedAt ? log.workflowUpdatedAt.toISOString() : null,
+ deleted: !log.workflowName || log.workflowArchivedAt !== null,
+ },
+ executionData,
+ cost: log.costTotal != null ? { total: Number(log.costTotal) } : null,
+ createdAt: log.createdAt.toISOString(),
+ }
+
+ return v2Data(detail, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Log detail fetch error`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts b/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts
new file mode 100644
index 00000000000..5b811960412
--- /dev/null
+++ b/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts
@@ -0,0 +1,79 @@
+import { db } from '@sim/db'
+import { workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { eq } from 'drizzle-orm'
+import type { NextRequest } from 'next/server'
+import { type V2Execution, v2GetExecutionContract } from '@/lib/api/contracts/v2/logs'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2ExecutionAPI')
+
+export const revalidate = 0
+
+export const GET = withRouteHandler(
+ async (request: NextRequest, context: { params: Promise<{ executionId: string }> }) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'logs-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2GetExecutionContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { executionId } = parsed.data.params
+
+ const rows = await db
+ .select()
+ .from(workflowExecutionLogs)
+ .where(eq(workflowExecutionLogs.executionId, executionId))
+ .limit(1)
+
+ if (rows.length === 0) return v2Error('NOT_FOUND', 'Workflow execution not found')
+
+ const workflowLog = rows[0]
+
+ // Convert an authorization failure into 404 so existence is not leaked.
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workflowLog.workspaceId)
+ if (access) return v2Error('NOT_FOUND', 'Workflow execution not found')
+
+ const [snapshot] = await db
+ .select()
+ .from(workflowExecutionSnapshots)
+ .where(eq(workflowExecutionSnapshots.id, workflowLog.stateSnapshotId))
+ .limit(1)
+
+ if (!snapshot) return v2Error('NOT_FOUND', 'Workflow state snapshot not found')
+
+ const execution: V2Execution = {
+ executionId,
+ workflowId: workflowLog.workflowId,
+ workflowState: snapshot.stateData,
+ executionMetadata: {
+ trigger: workflowLog.trigger,
+ startedAt: workflowLog.startedAt.toISOString(),
+ endedAt: workflowLog.endedAt ? workflowLog.endedAt.toISOString() : null,
+ totalDurationMs: workflowLog.totalDurationMs,
+ cost: workflowLog.costTotal != null ? { total: Number(workflowLog.costTotal) } : null,
+ },
+ }
+
+ return v2Data(execution, { rateLimit })
+ } catch (error) {
+ logger.error('Error fetching execution data', {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts
new file mode 100644
index 00000000000..ef1eb76e97b
--- /dev/null
+++ b/apps/sim/app/api/v2/logs/route.ts
@@ -0,0 +1,197 @@
+import { db } from '@sim/db'
+import { workflow, workflowExecutionLogs } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm'
+import type { NextRequest } from 'next/server'
+import { type V2LogListItem, v2ListLogsContract } from '@/lib/api/contracts/v2/logs'
+import { parseRequest } from '@/lib/api/server'
+import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { loadActiveFolderPathIndex } from '@/lib/folders/queries'
+import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
+import { buildLogFilters, getOrderBy } from '@/app/api/v1/logs/filters'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { resolveFolderPathId } from '@/app/api/v2/lib/folders'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ decodeCursor,
+ encodeCursor,
+ v2CursorList,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2LogsAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+export const GET = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'logs')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2ListLogsContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ const params = parsed.data.query
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const folderPaths = params.folderPaths?.split(',').filter(Boolean)
+ const folderIndex = folderPaths
+ ? await loadActiveFolderPathIndex(params.workspaceId, 'workflow')
+ : null
+ const resolvedFolderIds = folderPaths?.map((path) => resolveFolderPathId(folderIndex!, path))
+ if (resolvedFolderIds?.some((folderId) => folderId === undefined)) {
+ return v2Error('NOT_FOUND', 'Folder not found')
+ }
+ const nonRootFolderIds = resolvedFolderIds?.filter(
+ (folderId): folderId is string => typeof folderId === 'string'
+ )
+ const includesRoot = resolvedFolderIds?.includes(null) ?? false
+
+ const filters = {
+ workspaceId: params.workspaceId,
+ workflowIds: params.workflowIds?.split(',').filter(Boolean),
+ folderIds: nonRootFolderIds,
+ triggers: params.triggers?.split(',').filter(Boolean),
+ level: params.level,
+ startDate: params.startDate ? new Date(params.startDate) : undefined,
+ endDate: params.endDate ? new Date(params.endDate) : undefined,
+ executionId: params.executionId,
+ minDurationMs: params.minDurationMs,
+ maxDurationMs: params.maxDurationMs,
+ minCost: params.minCost,
+ maxCost: params.maxCost,
+ model: params.model,
+ cursor: params.cursor
+ ? decodeCursor<{ startedAt: string; id: string }>(params.cursor) || undefined
+ : undefined,
+ order: params.order,
+ }
+
+ const conditions = buildLogFilters(filters)
+ const rootFolderCondition = folderPaths
+ ? or(
+ includesRoot ? isNull(workflow.folderId) : undefined,
+ nonRootFolderIds && nonRootFolderIds.length > 0
+ ? inArray(workflow.folderId, nonRootFolderIds)
+ : undefined
+ )
+ : undefined
+ const orderBy = getOrderBy(params.order)
+
+ const rows = await db
+ .select({
+ id: workflowExecutionLogs.id,
+ workflowId: workflowExecutionLogs.workflowId,
+ workspaceId: workflowExecutionLogs.workspaceId,
+ executionId: workflowExecutionLogs.executionId,
+ deploymentVersionId: workflowExecutionLogs.deploymentVersionId,
+ level: workflowExecutionLogs.level,
+ trigger: workflowExecutionLogs.trigger,
+ startedAt: workflowExecutionLogs.startedAt,
+ endedAt: workflowExecutionLogs.endedAt,
+ totalDurationMs: workflowExecutionLogs.totalDurationMs,
+ costTotal: workflowExecutionLogs.costTotal,
+ files: workflowExecutionLogs.files,
+ executionData: params.details === 'full' ? workflowExecutionLogs.executionData : sql`null`,
+ workflowName: workflow.name,
+ workflowDescription: workflow.description,
+ workflowArchivedAt: workflow.archivedAt,
+ })
+ .from(workflowExecutionLogs)
+ .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
+ .where(and(conditions, rootFolderCondition))
+ .orderBy(...orderBy)
+ .limit(params.limit + 1)
+
+ const hasMore = rows.length > params.limit
+ const data = rows.slice(0, params.limit)
+
+ let nextCursor: string | null = null
+ if (hasMore && data.length > 0) {
+ const lastLog = data[data.length - 1]
+ nextCursor = encodeCursor({ startedAt: lastLog.startedAt.toISOString(), id: lastLog.id })
+ }
+
+ type LogRow = (typeof data)[number]
+ const buildItem = (log: LogRow): V2LogListItem => {
+ const item: V2LogListItem = {
+ id: log.id,
+ workflowId: log.workflowId,
+ executionId: log.executionId,
+ deploymentVersionId: log.deploymentVersionId,
+ level: log.level,
+ trigger: log.trigger,
+ startedAt: log.startedAt.toISOString(),
+ endedAt: log.endedAt ? log.endedAt.toISOString() : null,
+ totalDurationMs: log.totalDurationMs,
+ cost: log.costTotal != null ? { total: Number(log.costTotal) } : null,
+ files: (log.files as unknown[] | null) ?? null,
+ }
+ if (params.details === 'full') {
+ item.workflow = {
+ id: log.workflowId,
+ name: log.workflowName || 'Deleted Workflow',
+ description: log.workflowDescription,
+ deleted: !log.workflowName || log.workflowArchivedAt !== null,
+ }
+ }
+ return item
+ }
+
+ const needsMaterialize =
+ params.details === 'full' && (params.includeFinalOutput || params.includeTraceSpans)
+
+ const formattedLogs = needsMaterialize
+ ? await mapWithConcurrency(data, MATERIALIZE_CONCURRENCY, async (log) => {
+ const item = buildItem(log)
+ if (log.executionData) {
+ const execData = (await materializeExecutionData(
+ log.executionData as Record | null,
+ {
+ workspaceId: log.workspaceId,
+ workflowId: log.workflowId,
+ executionId: log.executionId,
+ }
+ )) as Record
+ if (params.includeFinalOutput && execData.finalOutput) {
+ item.finalOutput = execData.finalOutput
+ }
+ if (params.includeTraceSpans && execData.traceSpans) {
+ item.traceSpans = execData.traceSpans
+ }
+ }
+ return item
+ })
+ : data.map(buildItem)
+
+ return v2CursorList(formattedLogs, nextCursor, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Logs fetch error`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts b/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts
new file mode 100644
index 00000000000..1d5b93a53de
--- /dev/null
+++ b/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts
@@ -0,0 +1,350 @@
+/**
+ * @vitest-environment node
+ *
+ * Public v2 MCP server detail: gate ordering, contract validation, workspace
+ * access, and the thin-wrapper mapping onto `lib/mcp/orchestration`.
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { McpServerRow } from '@/lib/mcp/queries'
+
+const {
+ mockCheckRateLimit,
+ mockResolveWorkspaceAccess,
+ mockGetWorkspaceMcpServer,
+ mockPerformUpdateMcpServer,
+ mockPerformDeleteMcpServer,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceAccess: vi.fn(),
+ mockGetWorkspaceMcpServer: vi.fn(),
+ mockPerformUpdateMcpServer: vi.fn(),
+ mockPerformDeleteMcpServer: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceAccess: mockResolveWorkspaceAccess,
+}))
+
+vi.mock('@/lib/mcp/queries', () => ({
+ getWorkspaceMcpServer: mockGetWorkspaceMcpServer,
+}))
+
+vi.mock('@/lib/mcp/orchestration', () => ({
+ performUpdateMcpServer: mockPerformUpdateMcpServer,
+ performDeleteMcpServer: mockPerformDeleteMcpServer,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+import { DELETE, GET, PATCH } from '@/app/api/v2/mcp-servers/[id]/route'
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+}
+
+const RATE_LIMIT_DENIED = {
+ allowed: false,
+ limit: 100,
+ remaining: 0,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+ retryAfterMs: 1000,
+}
+
+const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' }
+
+function buildRow(overrides: Partial = {}): McpServerRow {
+ return {
+ id: 'mcp-abc12345',
+ workspaceId: 'workspace-1',
+ createdBy: 'user-1',
+ name: 'Docs server',
+ description: null,
+ transport: 'streamable-http',
+ url: 'https://mcp.example.com/sse',
+ authType: 'headers',
+ oauthClientId: null,
+ oauthClientSecret: 'encrypted-secret',
+ headers: { Authorization: 'Bearer super-secret-token' },
+ timeout: 30000,
+ retries: 3,
+ enabled: true,
+ lastConnected: null,
+ connectionStatus: 'disconnected',
+ lastError: null,
+ statusConfig: {},
+ toolCount: 0,
+ lastToolsRefresh: null,
+ totalRequests: 0,
+ lastUsed: null,
+ deletedAt: null,
+ createdAt: new Date('2024-01-01T00:00:00Z'),
+ updatedAt: new Date('2024-01-02T00:00:00Z'),
+ ...overrides,
+ } as McpServerRow
+}
+
+const routeContext = () => ({ params: Promise.resolve({ id: 'mcp-abc12345' }) })
+
+const url = (query = 'workspaceId=workspace-1') =>
+ `http://localhost:3000/api/v2/mcp-servers/mcp-abc12345?${query}`
+
+function callGet(query?: string) {
+ return GET(new NextRequest(url(query)), routeContext())
+}
+
+function callPatch(body: unknown) {
+ return PATCH(
+ new NextRequest('http://localhost:3000/api/v2/mcp-servers/mcp-abc12345', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ }),
+ routeContext()
+ )
+}
+
+function callDelete(query?: string) {
+ return DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext())
+}
+
+describe('GET /api/v2/mcp-servers/[id]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockGetWorkspaceMcpServer.mockResolvedValue(buildRow())
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callGet()
+
+ expect(res.status).toBe(404)
+ expect((await res.json()).error.code).toBe('NOT_FOUND')
+ expect(mockGetWorkspaceMcpServer).not.toHaveBeenCalled()
+ })
+
+ it('400s when workspaceId is missing', async () => {
+ const res = await callGet('')
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(mockGetWorkspaceMcpServer).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED)
+ const res = await callGet()
+ expect(res.status).toBe(403)
+ expect(mockGetWorkspaceMcpServer).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callGet()
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('404s when the server does not exist in the workspace', async () => {
+ mockGetWorkspaceMcpServer.mockResolvedValue(null)
+ const res = await callGet()
+ expect(res.status).toBe(404)
+ expect((await res.json()).error.code).toBe('NOT_FOUND')
+ })
+
+ it('returns the public server shape without header values', async () => {
+ const res = await callGet()
+ const body = await res.json()
+
+ expect(res.status).toBe(200)
+ expect(body.data.mcpServer).toMatchObject({
+ id: 'mcp-abc12345',
+ hasHeaders: true,
+ headerNames: ['Authorization'],
+ hasOauthClientSecret: true,
+ })
+ expect(JSON.stringify(body)).not.toContain('super-secret-token')
+ expect(JSON.stringify(body)).not.toContain('encrypted-secret')
+ expect(mockGetWorkspaceMcpServer).toHaveBeenCalledWith({
+ workspaceId: 'workspace-1',
+ serverId: 'mcp-abc12345',
+ })
+ })
+})
+
+describe('PATCH /api/v2/mcp-servers/[id]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockPerformUpdateMcpServer.mockResolvedValue({ success: true, server: buildRow() })
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed' })
+
+ expect(res.status).toBe(404)
+ expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled()
+ })
+
+ it('400s when the body has an unknown field', async () => {
+ const res = await callPatch({ workspaceId: 'workspace-1', bogus: true })
+ expect(res.status).toBe(400)
+ expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled()
+ })
+
+ it('400s when the url carries an environment-variable template', async () => {
+ const res = await callPatch({ workspaceId: 'workspace-1', url: 'https://{{HOST}}/sse' })
+ expect(res.status).toBe(400)
+ expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED)
+ const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed' })
+ expect(res.status).toBe(403)
+ expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed' })
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('maps a not_found orchestration failure to 404', async () => {
+ mockPerformUpdateMcpServer.mockResolvedValue({
+ success: false,
+ error: 'Server not found',
+ errorCode: 'not_found',
+ })
+ const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed' })
+ expect(res.status).toBe(404)
+ expect((await res.json()).error.code).toBe('NOT_FOUND')
+ })
+
+ it('400s when the url is changed, since the id is derived from it', async () => {
+ mockGetWorkspaceMcpServer.mockResolvedValue(buildRow())
+
+ const res = await callPatch({
+ workspaceId: 'workspace-1',
+ url: 'https://different.example.com/sse',
+ })
+
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.message).toContain('url cannot be changed')
+ expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled()
+ })
+
+ it('allows a url that matches the stored one, so a full-object PATCH still works', async () => {
+ mockGetWorkspaceMcpServer.mockResolvedValue(buildRow())
+
+ const res = await callPatch({
+ workspaceId: 'workspace-1',
+ url: 'https://mcp.example.com/sse',
+ enabled: false,
+ })
+
+ expect(res.status).toBe(200)
+ expect(mockPerformUpdateMcpServer).toHaveBeenCalled()
+ })
+
+ it('updates the server and returns the public shape', async () => {
+ const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed', enabled: false })
+ const body = await res.json()
+
+ expect(res.status).toBe(200)
+ expect(body.data.mcpServer.id).toBe('mcp-abc12345')
+ expect(body.data.mcpServer.headers).toBeUndefined()
+ expect(mockPerformUpdateMcpServer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workspaceId: 'workspace-1',
+ userId: 'user-1',
+ serverId: 'mcp-abc12345',
+ name: 'Renamed',
+ enabled: false,
+ })
+ )
+ })
+})
+
+describe('DELETE /api/v2/mcp-servers/[id]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockPerformDeleteMcpServer.mockResolvedValue({ success: true, server: buildRow() })
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callDelete()
+
+ expect(res.status).toBe(404)
+ expect(mockPerformDeleteMcpServer).not.toHaveBeenCalled()
+ })
+
+ it('400s when workspaceId is missing', async () => {
+ const res = await callDelete('')
+ expect(res.status).toBe(400)
+ expect(mockPerformDeleteMcpServer).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED)
+ const res = await callDelete()
+ expect(res.status).toBe(403)
+ expect(mockPerformDeleteMcpServer).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callDelete()
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('maps a not_found orchestration failure to 404', async () => {
+ mockPerformDeleteMcpServer.mockResolvedValue({
+ success: false,
+ error: 'Server not found',
+ errorCode: 'not_found',
+ })
+ const res = await callDelete()
+ expect(res.status).toBe(404)
+ expect((await res.json()).error.code).toBe('NOT_FOUND')
+ })
+
+ it('deletes the server and acknowledges the id', async () => {
+ const res = await callDelete()
+ expect(res.status).toBe(200)
+ expect(await res.json()).toEqual({ data: { id: 'mcp-abc12345', deleted: true } })
+ expect(mockPerformDeleteMcpServer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workspaceId: 'workspace-1',
+ userId: 'user-1',
+ serverId: 'mcp-abc12345',
+ })
+ )
+ })
+})
diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/route.ts b/apps/sim/app/api/v2/mcp-servers/[id]/route.ts
new file mode 100644
index 00000000000..22231ef8eba
--- /dev/null
+++ b/apps/sim/app/api/v2/mcp-servers/[id]/route.ts
@@ -0,0 +1,181 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import {
+ v2DeleteMcpServerContract,
+ v2GetMcpServerContract,
+ v2UpdateMcpServerContract,
+} from '@/lib/api/contracts/v2/mcp-servers'
+import { parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { performDeleteMcpServer, performUpdateMcpServer } from '@/lib/mcp/orchestration'
+import { getWorkspaceMcpServer } from '@/lib/mcp/queries'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+import { toV2McpServer, v2McpOrchestrationError } from '@/app/api/v2/mcp-servers/utils'
+
+const logger = createLogger('V2McpServerDetailAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+interface RouteContext {
+ params: Promise<{ id: string }>
+}
+
+/** GET /api/v2/mcp-servers/[id] — Fetch a single MCP server. */
+export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'mcp-server-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2GetMcpServerContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const server = await getWorkspaceMcpServer({ workspaceId, serverId: id })
+ if (!server) return v2Error('NOT_FOUND', 'MCP server not found')
+
+ return v2Data({ mcpServer: toV2McpServer(server) }, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Error fetching MCP server`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/** PATCH /api/v2/mcp-servers/[id] — Update an MCP server's configuration. */
+export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'mcp-server-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2UpdateMcpServerContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+ const { workspaceId, ...body } = parsed.data.body
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+
+ /**
+ * A server's id is the hash of its workspace + URL, and this surface promises
+ * that identity. The lib will happily move `url` while the id keeps hashing
+ * the old one, which both breaks that promise and defeats the duplicate
+ * check on create (id-keyed, so it would not see the moved URL) — leaving two
+ * rows on one URL. Re-pointing a server at a different URL is a new server.
+ */
+ if (body.url !== undefined) {
+ const current = await getWorkspaceMcpServer({ workspaceId, serverId: id })
+ if (!current) return v2Error('NOT_FOUND', 'MCP server not found')
+ if (current.url !== body.url) {
+ return v2Error(
+ 'BAD_REQUEST',
+ 'url cannot be changed: an MCP server’s id is derived from its URL. Delete this server and create one at the new URL.'
+ )
+ }
+ }
+
+ const result = await performUpdateMcpServer({
+ workspaceId,
+ userId,
+ serverId: id,
+ name: body.name,
+ description: body.description,
+ transport: body.transport,
+ url: body.url,
+ headers: body.headers,
+ timeout: body.timeout,
+ retries: body.retries,
+ enabled: body.enabled,
+ authType: body.authType,
+ oauthClientId: body.oauthClientId ?? null,
+ oauthClientIdProvided: body.oauthClientId !== undefined,
+ oauthClientSecret: body.oauthClientSecret,
+ oauthClientSecretProvided: body.oauthClientSecret !== undefined,
+ request,
+ })
+
+ if (!result.success || !result.server) {
+ return v2McpOrchestrationError(result.errorCode, result.error ?? 'Failed to update server')
+ }
+
+ return v2Data({ mcpServer: toV2McpServer(result.server) }, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Error updating MCP server`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/** DELETE /api/v2/mcp-servers/[id] — Remove an MCP server from the workspace. */
+export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'mcp-server-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2DeleteMcpServerContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const result = await performDeleteMcpServer({ workspaceId, userId, serverId: id, request })
+ if (!result.success) {
+ return v2McpOrchestrationError(result.errorCode, result.error ?? 'Failed to delete server')
+ }
+
+ return v2Data({ id, deleted: true as const }, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Error deleting MCP server`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/mcp-servers/route.test.ts b/apps/sim/app/api/v2/mcp-servers/route.test.ts
new file mode 100644
index 00000000000..cb0df3ac683
--- /dev/null
+++ b/apps/sim/app/api/v2/mcp-servers/route.test.ts
@@ -0,0 +1,365 @@
+/**
+ * @vitest-environment node
+ *
+ * Public v2 MCP servers list/create: gate ordering, contract validation, the
+ * write-only `headers` projection, and the 409-on-duplicate-URL departure from
+ * the internal upsert.
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { McpServerRow } from '@/lib/mcp/queries'
+
+const {
+ mockCheckRateLimit,
+ mockResolveWorkspaceAccess,
+ mockListWorkspaceMcpServers,
+ mockGetWorkspaceMcpServer,
+ mockGetMcpServerIdState,
+ mockPerformCreateMcpServer,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceAccess: vi.fn(),
+ mockListWorkspaceMcpServers: vi.fn(),
+ mockGetWorkspaceMcpServer: vi.fn(),
+ mockGetMcpServerIdState: vi.fn(),
+ mockPerformCreateMcpServer: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceAccess: mockResolveWorkspaceAccess,
+}))
+
+vi.mock('@/lib/mcp/queries', () => ({
+ listWorkspaceMcpServers: mockListWorkspaceMcpServers,
+ getWorkspaceMcpServer: mockGetWorkspaceMcpServer,
+ getMcpServerIdState: mockGetMcpServerIdState,
+}))
+
+vi.mock('@/lib/mcp/orchestration', () => ({
+ performCreateMcpServer: mockPerformCreateMcpServer,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+import { GET, POST } from '@/app/api/v2/mcp-servers/route'
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+}
+
+function buildRow(overrides: Partial = {}): McpServerRow {
+ return {
+ id: 'mcp-abc12345',
+ workspaceId: 'workspace-1',
+ createdBy: 'user-1',
+ name: 'Docs server',
+ description: 'Internal docs',
+ transport: 'streamable-http',
+ url: 'https://mcp.example.com/sse',
+ authType: 'headers',
+ oauthClientId: null,
+ oauthClientSecret: null,
+ headers: { Authorization: 'Bearer super-secret-token' },
+ timeout: 30000,
+ retries: 3,
+ enabled: true,
+ lastConnected: new Date('2024-01-02T00:00:00Z'),
+ connectionStatus: 'connected',
+ lastError: null,
+ statusConfig: {},
+ toolCount: 4,
+ lastToolsRefresh: new Date('2024-01-02T00:00:00Z'),
+ totalRequests: 0,
+ lastUsed: null,
+ deletedAt: null,
+ createdAt: new Date('2024-01-01T00:00:00Z'),
+ updatedAt: new Date('2024-01-02T00:00:00Z'),
+ ...overrides,
+ } as McpServerRow
+}
+
+function callList(query: string) {
+ return GET(new NextRequest(`http://localhost:3000/api/v2/mcp-servers?${query}`))
+}
+
+function callCreate(body: unknown) {
+ return POST(
+ new NextRequest('http://localhost:3000/api/v2/mcp-servers', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ )
+}
+
+/** What the route forwards for a bare `?workspaceId=` list. */
+const DEFAULT_LIST_ARGS = {
+ search: undefined,
+ sortBy: 'createdAt',
+ sortOrder: 'desc',
+}
+
+const VALID_BODY = {
+ workspaceId: 'workspace-1',
+ name: 'Docs server',
+ url: 'https://mcp.example.com/sse',
+}
+
+describe('GET /api/v2/mcp-servers', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockListWorkspaceMcpServers.mockResolvedValue([buildRow()])
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callList('workspaceId=workspace-1')
+
+ expect(res.status).toBe(404)
+ expect((await res.json()).error.code).toBe('NOT_FOUND')
+ expect(mockListWorkspaceMcpServers).not.toHaveBeenCalled()
+ })
+
+ it('400s when workspaceId is missing', async () => {
+ const res = await callList('')
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(mockListWorkspaceMcpServers).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue({
+ status: 403,
+ code: 'FORBIDDEN',
+ message: 'Access denied',
+ })
+ const res = await callList('workspaceId=workspace-1')
+ expect(res.status).toBe(403)
+ expect((await res.json()).error).toMatchObject({ code: 'FORBIDDEN', message: 'Access denied' })
+ expect(mockListWorkspaceMcpServers).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue({
+ allowed: false,
+ limit: 100,
+ remaining: 0,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+ retryAfterMs: 1000,
+ })
+ const res = await callList('workspaceId=workspace-1')
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('returns the public server shape in the cursor envelope', async () => {
+ const res = await callList('workspaceId=workspace-1')
+ const body = await res.json()
+
+ expect(res.status).toBe(200)
+ expect(body.nextCursor).toBeNull()
+ expect(body.data).toEqual([
+ {
+ id: 'mcp-abc12345',
+ name: 'Docs server',
+ description: 'Internal docs',
+ transport: 'streamable-http',
+ authType: 'headers',
+ url: 'https://mcp.example.com/sse',
+ timeout: 30000,
+ retries: 3,
+ enabled: true,
+ connectionStatus: 'connected',
+ lastError: null,
+ toolCount: 4,
+ lastToolsRefresh: '2024-01-02T00:00:00.000Z',
+ lastConnected: '2024-01-02T00:00:00.000Z',
+ createdAt: '2024-01-01T00:00:00.000Z',
+ updatedAt: '2024-01-02T00:00:00.000Z',
+ hasHeaders: true,
+ headerNames: ['Authorization'],
+ hasOauthClientSecret: false,
+ },
+ ])
+ expect(mockListWorkspaceMcpServers).toHaveBeenCalledWith({
+ workspaceId: 'workspace-1',
+ ...DEFAULT_LIST_ARGS,
+ })
+ })
+
+ it('never returns configured header values', async () => {
+ const res = await callList('workspaceId=workspace-1')
+ const raw = JSON.stringify(await res.json())
+
+ expect(raw).not.toContain('super-secret-token')
+ expect(raw).not.toContain('"headers":')
+ })
+ it('400s on a sort field outside the enum instead of letting it reach the query', async () => {
+ const res = await callList(`workspaceId=workspace-1&sortBy=name);--`)
+
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ })
+
+ it('400s on a sort direction outside the enum', async () => {
+ const res = await callList(`workspaceId=workspace-1&sortOrder=sideways`)
+
+ expect(res.status).toBe(400)
+ })
+
+ it('400s on an empty search rather than treating it as unsearched', async () => {
+ const res = await callList(`workspaceId=workspace-1&search=`)
+
+ expect(res.status).toBe(400)
+ })
+
+ it('forwards search and sort into the query and still terminates pagination', async () => {
+ const res = await callList(`workspaceId=workspace-1&search=report&sortBy=name&sortOrder=asc`)
+
+ expect(res.status).toBe(200)
+ expect((await res.json()).nextCursor).toBeNull()
+ })
+})
+
+describe('POST /api/v2/mcp-servers', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockGetMcpServerIdState.mockResolvedValue(null)
+ mockPerformCreateMcpServer.mockResolvedValue({
+ success: true,
+ serverId: 'mcp-abc12345',
+ updated: false,
+ })
+ mockGetWorkspaceMcpServer.mockResolvedValue(buildRow())
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callCreate(VALID_BODY)
+
+ expect(res.status).toBe(404)
+ expect(mockPerformCreateMcpServer).not.toHaveBeenCalled()
+ })
+
+ it('400s when the body is missing a required field', async () => {
+ const res = await callCreate({ workspaceId: 'workspace-1', name: 'Docs server' })
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(mockPerformCreateMcpServer).not.toHaveBeenCalled()
+ })
+
+ it('400s when the url carries an environment-variable template', async () => {
+ const res = await callCreate({ ...VALID_BODY, url: 'https://{{MCP_HOST}}/sse' })
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.message).toContain('{{ENV_VAR}}')
+ expect(mockPerformCreateMcpServer).not.toHaveBeenCalled()
+ })
+
+ it('400s when the url is not an absolute http(s) URL', async () => {
+ const res = await callCreate({ ...VALID_BODY, url: 'file:///etc/passwd' })
+ expect(res.status).toBe(400)
+ expect(mockPerformCreateMcpServer).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue({
+ status: 403,
+ code: 'FORBIDDEN',
+ message: 'Access denied',
+ })
+ const res = await callCreate(VALID_BODY)
+ expect(res.status).toBe(403)
+ expect(mockPerformCreateMcpServer).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue({
+ allowed: false,
+ limit: 100,
+ remaining: 0,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+ retryAfterMs: 1000,
+ })
+ const res = await callCreate(VALID_BODY)
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('409s on a duplicate URL without letting the lib upsert', async () => {
+ mockGetMcpServerIdState.mockResolvedValue({ deleted: false })
+
+ const res = await callCreate(VALID_BODY)
+
+ expect(res.status).toBe(409)
+ expect((await res.json()).error.code).toBe('CONFLICT')
+ expect(mockPerformCreateMcpServer).not.toHaveBeenCalled()
+ })
+
+ it('409s when a concurrent create made the lib upsert instead of insert', async () => {
+ mockPerformCreateMcpServer.mockResolvedValue({
+ success: true,
+ serverId: 'mcp-abc12345',
+ updated: true,
+ })
+
+ const res = await callCreate(VALID_BODY)
+
+ expect(res.status).toBe(409)
+ expect((await res.json()).error.code).toBe('CONFLICT')
+ })
+
+ it('revives a soft-deleted URL instead of stranding it behind a 409', async () => {
+ mockGetMcpServerIdState.mockResolvedValue({ deleted: true })
+ mockPerformCreateMcpServer.mockResolvedValue({
+ success: true,
+ serverId: 'mcp-abc12345',
+ updated: true,
+ })
+
+ const res = await callCreate(VALID_BODY)
+
+ expect(res.status).toBe(201)
+ expect(mockPerformCreateMcpServer).toHaveBeenCalled()
+ })
+
+ it('creates the server and returns 201 with the public shape', async () => {
+ const res = await callCreate({ ...VALID_BODY, headers: { Authorization: 'Bearer tok' } })
+ const body = await res.json()
+
+ expect(res.status).toBe(201)
+ expect(body.data.mcpServer).toMatchObject({
+ id: 'mcp-abc12345',
+ name: 'Docs server',
+ hasHeaders: true,
+ headerNames: ['Authorization'],
+ })
+ expect(body.data.mcpServer.headers).toBeUndefined()
+ expect(mockPerformCreateMcpServer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workspaceId: 'workspace-1',
+ userId: 'user-1',
+ name: 'Docs server',
+ url: 'https://mcp.example.com/sse',
+ headers: { Authorization: 'Bearer tok' },
+ })
+ )
+ })
+})
diff --git a/apps/sim/app/api/v2/mcp-servers/route.ts b/apps/sim/app/api/v2/mcp-servers/route.ts
new file mode 100644
index 00000000000..e9a32f7251f
--- /dev/null
+++ b/apps/sim/app/api/v2/mcp-servers/route.ts
@@ -0,0 +1,167 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import {
+ v2CreateMcpServerContract,
+ v2ListMcpServersContract,
+} from '@/lib/api/contracts/v2/mcp-servers'
+import { parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { performCreateMcpServer } from '@/lib/mcp/orchestration'
+import {
+ getMcpServerIdState,
+ getWorkspaceMcpServer,
+ listWorkspaceMcpServers,
+} from '@/lib/mcp/queries'
+import { generateMcpServerId } from '@/lib/mcp/utils'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CursorList,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+import { toV2McpServer, v2McpOrchestrationError } from '@/app/api/v2/mcp-servers/utils'
+
+const logger = createLogger('V2McpServersAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+/** GET /api/v2/mcp-servers — List MCP servers in a workspace. */
+export const GET = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'mcp-servers')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2ListMcpServersContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ const { workspaceId, search, sortBy, sortOrder } = parsed.data.query
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const rows = await listWorkspaceMcpServers({ workspaceId, search, sortBy, sortOrder })
+
+ // The per-workspace server set is small and bounded → a single full page.
+ return v2CursorList(rows.map(toV2McpServer), null, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Error listing MCP servers`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/** POST /api/v2/mcp-servers — Register a new MCP server. */
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'mcp-servers')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2CreateMcpServerContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ const { workspaceId, ...body } = parsed.data.body
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+
+ /**
+ * The server id is a deterministic hash of workspace + normalized URL, and
+ * `performCreateMcpServer` upserts onto it — a second registration of the
+ * same URL silently overwrites the first. The internal surface and the
+ * copilot rely on that; a public create must not, so the collision is
+ * detected here, before the lib is given a chance to clobber the row.
+ *
+ * Only a *live* row is a conflict. A soft-deleted one is revived by the lib
+ * rather than inserted alongside, and reporting it as a duplicate would
+ * strand that URL for good: the detail routes resolve live rows only, so it
+ * could be neither fetched, patched, nor re-created.
+ */
+ const serverId = generateMcpServerId(workspaceId, body.url)
+ const idState = await getMcpServerIdState({ workspaceId, serverId })
+ if (idState && !idState.deleted) {
+ return v2Error(
+ 'CONFLICT',
+ 'An MCP server with this URL already exists in this workspace. Update it with PATCH /api/v2/mcp-servers/{id}.'
+ )
+ }
+ const revivingSoftDeleted = idState?.deleted === true
+
+ const result = await performCreateMcpServer({
+ workspaceId,
+ userId,
+ name: body.name,
+ description: body.description,
+ transport: body.transport,
+ url: body.url,
+ headers: body.headers,
+ timeout: body.timeout,
+ retries: body.retries,
+ enabled: body.enabled,
+ authType: body.authType,
+ oauthClientId: body.oauthClientId ?? null,
+ oauthClientIdProvided: body.oauthClientId !== undefined,
+ oauthClientSecret: body.oauthClientSecret,
+ oauthClientSecretProvided: body.oauthClientSecret !== undefined,
+ request,
+ })
+
+ if (!result.success || !result.serverId) {
+ return v2McpOrchestrationError(result.errorCode, result.error ?? 'Failed to register server')
+ }
+
+ /**
+ * `updated` means the lib wrote onto an existing row. Reviving the
+ * soft-deleted row we already saw is the intended outcome; otherwise a
+ * concurrent create won the id race between the check above and the write.
+ */
+ if (result.updated && !revivingSoftDeleted) {
+ return v2Error('CONFLICT', 'An MCP server with this URL already exists in this workspace.')
+ }
+
+ const created = await getWorkspaceMcpServer({ workspaceId, serverId: result.serverId })
+ if (!created) return v2Error('INTERNAL_ERROR', 'Internal server error')
+
+ return v2Data({ mcpServer: toV2McpServer(created) }, { rateLimit, status: 201 })
+ } catch (error) {
+ logger.error(`[${requestId}] Error creating MCP server`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/mcp-servers/utils.ts b/apps/sim/app/api/v2/mcp-servers/utils.ts
new file mode 100644
index 00000000000..ba4fec6ee87
--- /dev/null
+++ b/apps/sim/app/api/v2/mcp-servers/utils.ts
@@ -0,0 +1,50 @@
+import type { NextResponse } from 'next/server'
+import { type V2McpServer, v2McpServerSchema } from '@/lib/api/contracts/v2/mcp-servers'
+import type { McpServerRow } from '@/lib/mcp/queries'
+import { v2Error } from '@/app/api/v2/lib/response'
+
+/**
+ * Shared serialization + error mapping for the v2 MCP server surface.
+ */
+
+/**
+ * Projects a stored MCP server row onto the public shape.
+ *
+ * The row is parsed through {@link v2McpServerSchema}, whose strip behaviour is
+ * the security boundary: `headers`, `oauthClientSecret`, `statusConfig`, and the
+ * rest of the row are dropped rather than enumerated by hand, so a column added
+ * later cannot leak by omission. Header *names* are lifted out explicitly.
+ */
+export function toV2McpServer(row: McpServerRow): V2McpServer {
+ const headers = (row.headers ?? {}) as Record
+ const headerNames = Object.keys(headers)
+ return v2McpServerSchema.parse({
+ ...row,
+ hasHeaders: headerNames.length > 0,
+ headerNames,
+ hasOauthClientSecret: Boolean(row.oauthClientSecret),
+ })
+}
+
+/**
+ * Renders an MCP orchestration failure in the v2 error envelope.
+ *
+ * `forbidden` is the domain-allowlist / SSRF rejection and keeps its 403.
+ * `bad_gateway` is a DNS failure on the caller-supplied hostname — the caller's
+ * input is at fault, so it surfaces as a 400 rather than implying a Sim outage.
+ */
+export function v2McpOrchestrationError(
+ errorCode: string | undefined,
+ message: string
+): NextResponse {
+ switch (errorCode) {
+ case 'not_found':
+ return v2Error('NOT_FOUND', 'MCP server not found')
+ case 'forbidden':
+ return v2Error('FORBIDDEN', message)
+ case 'bad_gateway':
+ return v2Error('BAD_REQUEST', message)
+ default:
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+}
diff --git a/apps/sim/app/api/v2/skills/[id]/route.test.ts b/apps/sim/app/api/v2/skills/[id]/route.test.ts
new file mode 100644
index 00000000000..834191497ff
--- /dev/null
+++ b/apps/sim/app/api/v2/skills/[id]/route.test.ts
@@ -0,0 +1,331 @@
+/**
+ * @vitest-environment node
+ *
+ * Public v2 skill detail: the get-by-id that has no internal equivalent, plus
+ * the per-id update/delete that replaced the bulk upsert.
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockResolveWorkspaceAccess,
+ mockGetSkillById,
+ mockPerformUpdateSkill,
+ mockPerformDeleteSkill,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceAccess: vi.fn(),
+ mockGetSkillById: vi.fn(),
+ mockPerformUpdateSkill: vi.fn(),
+ mockPerformDeleteSkill: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceAccess: mockResolveWorkspaceAccess,
+}))
+
+vi.mock('@/lib/workflows/skills/operations', () => ({
+ getSkillById: mockGetSkillById,
+}))
+
+vi.mock('@/lib/skills/orchestration', () => ({
+ performUpdateSkill: mockPerformUpdateSkill,
+ performDeleteSkill: mockPerformDeleteSkill,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+import { DELETE, GET, PATCH } from '@/app/api/v2/skills/[id]/route'
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+}
+
+const RATE_LIMIT_DENIED = {
+ allowed: false,
+ limit: 100,
+ remaining: 0,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+ retryAfterMs: 1000,
+}
+
+const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' }
+
+function buildSkill(overrides: Record = {}) {
+ return {
+ id: 'skl_abc123',
+ workspaceId: 'workspace-1',
+ userId: 'user-1',
+ name: 'refund-policy',
+ description: 'How to handle refunds',
+ content: '# Refund policy',
+ createdAt: new Date('2024-01-01T00:00:00Z'),
+ updatedAt: new Date('2024-01-02T00:00:00Z'),
+ ...overrides,
+ }
+}
+
+const routeContext = () => ({ params: Promise.resolve({ id: 'skl_abc123' }) })
+const url = (query = 'workspaceId=workspace-1') =>
+ `http://localhost:3000/api/v2/skills/skl_abc123?${query}`
+
+const callGet = (query?: string) => GET(new NextRequest(url(query)), routeContext())
+const callDelete = (query?: string) =>
+ DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext())
+
+function callPatch(body: unknown) {
+ return PATCH(
+ new NextRequest('http://localhost:3000/api/v2/skills/skl_abc123', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ }),
+ routeContext()
+ )
+}
+
+describe('GET /api/v2/skills/[id]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockGetSkillById.mockResolvedValue(buildSkill())
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callGet()
+
+ expect(res.status).toBe(404)
+ expect(mockGetSkillById).not.toHaveBeenCalled()
+ })
+
+ it('400s when workspaceId is missing', async () => {
+ const res = await callGet('')
+ expect(res.status).toBe(400)
+ expect(mockGetSkillById).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED)
+ const res = await callGet()
+ expect(res.status).toBe(403)
+ expect(mockGetSkillById).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callGet()
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('404s when the skill is not in the workspace', async () => {
+ mockGetSkillById.mockResolvedValue(null)
+ const res = await callGet()
+ expect(res.status).toBe(404)
+ expect((await res.json()).error.code).toBe('NOT_FOUND')
+ })
+
+ it('returns the single skill including its body', async () => {
+ const res = await callGet()
+ const body = await res.json()
+
+ expect(res.status).toBe(200)
+ expect(body.data).toEqual({
+ skill: {
+ id: 'skl_abc123',
+ name: 'refund-policy',
+ description: 'How to handle refunds',
+ content: '# Refund policy',
+ readOnly: false,
+ createdAt: '2024-01-01T00:00:00.000Z',
+ updatedAt: '2024-01-02T00:00:00.000Z',
+ },
+ })
+ expect(mockGetSkillById).toHaveBeenCalledWith({
+ skillId: 'skl_abc123',
+ workspaceId: 'workspace-1',
+ })
+ })
+})
+
+describe('PATCH /api/v2/skills/[id]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockPerformUpdateSkill.mockResolvedValue({
+ success: true,
+ skill: buildSkill({ description: 'Updated' }),
+ })
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' })
+
+ expect(res.status).toBe(404)
+ expect(mockPerformUpdateSkill).not.toHaveBeenCalled()
+ })
+
+ it('400s when no field to change is supplied', async () => {
+ const res = await callPatch({ workspaceId: 'workspace-1' })
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(mockPerformUpdateSkill).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED)
+ const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' })
+ expect(res.status).toBe(403)
+ expect(mockPerformUpdateSkill).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' })
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('403s when the caller is not a skill editor', async () => {
+ mockPerformUpdateSkill.mockResolvedValue({
+ success: false,
+ error: 'Skill editor access required to modify "refund-policy"',
+ errorCode: 'forbidden',
+ })
+ const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' })
+ expect(res.status).toBe(403)
+ expect((await res.json()).error.code).toBe('FORBIDDEN')
+ })
+
+ it('400s when the orchestration rejects a built-in skill', async () => {
+ mockPerformUpdateSkill.mockResolvedValue({
+ success: false,
+ error: 'Built-in skills are read-only and cannot be modified',
+ errorCode: 'validation',
+ })
+ const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' })
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.message).toContain('Built-in')
+ })
+
+ it('gates on workspace read, leaving edit rights to the per-skill editor check', async () => {
+ await callPatch({ workspaceId: 'workspace-1', description: 'Updated' })
+ expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(
+ expect.anything(),
+ 'user-1',
+ 'workspace-1',
+ 'read'
+ )
+ })
+
+ it('updates the skill and returns the single skill', async () => {
+ const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' })
+ const body = await res.json()
+
+ expect(res.status).toBe(200)
+ expect(body.data.skill.description).toBe('Updated')
+ expect(Array.isArray(body.data)).toBe(false)
+ expect(mockPerformUpdateSkill).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workspaceId: 'workspace-1',
+ userId: 'user-1',
+ skillId: 'skl_abc123',
+ description: 'Updated',
+ source: 'api',
+ })
+ )
+ })
+})
+
+describe('DELETE /api/v2/skills/[id]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockPerformDeleteSkill.mockResolvedValue({ success: true, skill: buildSkill() })
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callDelete()
+
+ expect(res.status).toBe(404)
+ expect(mockPerformDeleteSkill).not.toHaveBeenCalled()
+ })
+
+ it('400s when workspaceId is missing', async () => {
+ const res = await callDelete('')
+ expect(res.status).toBe(400)
+ expect(mockPerformDeleteSkill).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED)
+ const res = await callDelete()
+ expect(res.status).toBe(403)
+ expect(mockPerformDeleteSkill).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callDelete()
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('400s when the skill is a read-only built-in', async () => {
+ mockPerformDeleteSkill.mockResolvedValue({
+ success: false,
+ error: 'Built-in skills are read-only and cannot be modified',
+ errorCode: 'validation',
+ })
+ const res = await callDelete()
+ expect(res.status).toBe(400)
+ })
+
+ it('gates on workspace read, leaving delete rights to the per-skill editor check', async () => {
+ await callDelete()
+ expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(
+ expect.anything(),
+ 'user-1',
+ 'workspace-1',
+ 'read'
+ )
+ })
+
+ it('deletes the skill and acknowledges the id', async () => {
+ const res = await callDelete()
+ expect(res.status).toBe(200)
+ expect(await res.json()).toEqual({ data: { id: 'skl_abc123', deleted: true } })
+ expect(mockPerformDeleteSkill).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workspaceId: 'workspace-1',
+ userId: 'user-1',
+ skillId: 'skl_abc123',
+ source: 'api',
+ })
+ )
+ })
+})
diff --git a/apps/sim/app/api/v2/skills/[id]/route.ts b/apps/sim/app/api/v2/skills/[id]/route.ts
new file mode 100644
index 00000000000..2cb7d1f2017
--- /dev/null
+++ b/apps/sim/app/api/v2/skills/[id]/route.ts
@@ -0,0 +1,169 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import {
+ v2DeleteSkillContract,
+ v2GetSkillContract,
+ v2UpdateSkillContract,
+} from '@/lib/api/contracts/v2/skills'
+import { parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { performDeleteSkill, performUpdateSkill } from '@/lib/skills/orchestration'
+import { getSkillById } from '@/lib/workflows/skills/operations'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+import { toV2Skill, v2SkillOrchestrationError } from '@/app/api/v2/skills/utils'
+
+const logger = createLogger('V2SkillDetailAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+interface RouteContext {
+ params: Promise<{ id: string }>
+}
+
+/** GET /api/v2/skills/[id] — Fetch a single skill, including its body. */
+export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'skill-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2GetSkillContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const skill = await getSkillById({ skillId: id, workspaceId })
+ if (!skill) return v2Error('NOT_FOUND', 'Skill not found')
+
+ return v2Data({ skill: toV2Skill(skill) }, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Error fetching skill`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/** PATCH /api/v2/skills/[id] — Update a skill. Omitted fields keep their values. */
+export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'skill-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2UpdateSkillContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+ const { workspaceId, name, description, content } = parsed.data.body
+
+ /**
+ * Editing an existing skill is gated per skill, not per workspace: an
+ * explicit editor grant (or workspace admin) is the authority, and
+ * `performUpdateSkill` enforces it. Requiring workspace `write` here would
+ * reject a legitimate skill editor who only holds `read` — stricter than the
+ * UI and than what this endpoint documents. Creating still needs `write`.
+ */
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const result = await performUpdateSkill({
+ workspaceId,
+ userId,
+ skillId: id,
+ name,
+ description,
+ content,
+ source: 'api',
+ request,
+ })
+
+ if (!result.success || !result.skill) {
+ return v2SkillOrchestrationError(result.errorCode, result.error ?? 'Failed to update skill')
+ }
+
+ return v2Data({ skill: toV2Skill(result.skill) }, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Error updating skill`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/** DELETE /api/v2/skills/[id] — Delete a skill. */
+export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'skill-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2DeleteSkillContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+
+ // Gated per skill by `performDeleteSkill`, same as PATCH above.
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const result = await performDeleteSkill({
+ workspaceId,
+ userId,
+ skillId: id,
+ source: 'api',
+ request,
+ })
+
+ if (!result.success) {
+ return v2SkillOrchestrationError(result.errorCode, result.error ?? 'Failed to delete skill')
+ }
+
+ return v2Data({ id, deleted: true as const }, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Error deleting skill`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/skills/route.test.ts b/apps/sim/app/api/v2/skills/route.test.ts
new file mode 100644
index 00000000000..8e1c5131c2e
--- /dev/null
+++ b/apps/sim/app/api/v2/skills/route.test.ts
@@ -0,0 +1,290 @@
+/**
+ * @vitest-environment node
+ *
+ * Public v2 skills list/create: gate ordering, contract validation, and the
+ * single-resource create that replaced the internal bulk upsert.
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockListSkills, mockPerformCreateSkill } =
+ vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceAccess: vi.fn(),
+ mockListSkills: vi.fn(),
+ mockPerformCreateSkill: vi.fn(),
+ }))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceAccess: mockResolveWorkspaceAccess,
+}))
+
+vi.mock('@/lib/workflows/skills/operations', () => ({
+ listSkills: mockListSkills,
+}))
+
+vi.mock('@/lib/skills/orchestration', () => ({
+ performCreateSkill: mockPerformCreateSkill,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+import { GET, POST } from '@/app/api/v2/skills/route'
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+}
+
+const RATE_LIMIT_DENIED = {
+ allowed: false,
+ limit: 100,
+ remaining: 0,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+ retryAfterMs: 1000,
+}
+
+function buildSkill(overrides: Record = {}) {
+ return {
+ id: 'skl_abc123',
+ workspaceId: 'workspace-1',
+ userId: 'user-1',
+ name: 'refund-policy',
+ description: 'How to handle refunds',
+ content: '# Refund policy\n\nAlways be kind.',
+ createdAt: new Date('2024-01-01T00:00:00Z'),
+ updatedAt: new Date('2024-01-02T00:00:00Z'),
+ ...overrides,
+ }
+}
+
+function callList(query: string) {
+ return GET(new NextRequest(`http://localhost:3000/api/v2/skills?${query}`))
+}
+
+function callCreate(body: unknown) {
+ return POST(
+ new NextRequest('http://localhost:3000/api/v2/skills', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ )
+}
+
+const VALID_BODY = {
+ workspaceId: 'workspace-1',
+ name: 'refund-policy',
+ description: 'How to handle refunds',
+ content: '# Refund policy',
+}
+
+describe('GET /api/v2/skills', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockListSkills.mockResolvedValue([buildSkill()])
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callList('workspaceId=workspace-1')
+
+ expect(res.status).toBe(404)
+ expect((await res.json()).error.code).toBe('NOT_FOUND')
+ expect(mockListSkills).not.toHaveBeenCalled()
+ })
+
+ it('400s when workspaceId is missing', async () => {
+ const res = await callList('')
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(mockListSkills).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue({
+ status: 403,
+ code: 'FORBIDDEN',
+ message: 'Access denied',
+ })
+ const res = await callList('workspaceId=workspace-1')
+ expect(res.status).toBe(403)
+ expect(mockListSkills).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callList('workspaceId=workspace-1')
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('returns summaries without skill bodies in the cursor envelope', async () => {
+ const res = await callList('workspaceId=workspace-1')
+ const body = await res.json()
+
+ expect(res.status).toBe(200)
+ expect(body.nextCursor).toBeNull()
+ expect(body.data).toEqual([
+ {
+ id: 'skl_abc123',
+ name: 'refund-policy',
+ description: 'How to handle refunds',
+ readOnly: false,
+ createdAt: '2024-01-01T00:00:00.000Z',
+ updatedAt: '2024-01-02T00:00:00.000Z',
+ },
+ ])
+ expect(mockListSkills).toHaveBeenCalledWith({
+ workspaceId: 'workspace-1',
+ search: undefined,
+ sort: { sortBy: 'createdAt', sortOrder: 'desc' },
+ })
+ })
+ it('400s on a sort field outside the enum instead of letting it reach the query', async () => {
+ const res = await callList(`workspaceId=workspace-1&sortBy=name);--`)
+
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ })
+
+ it('400s on a sort direction outside the enum', async () => {
+ const res = await callList(`workspaceId=workspace-1&sortOrder=sideways`)
+
+ expect(res.status).toBe(400)
+ })
+
+ it('400s on an empty search rather than treating it as unsearched', async () => {
+ const res = await callList(`workspaceId=workspace-1&search=`)
+
+ expect(res.status).toBe(400)
+ })
+
+ it('forwards search and sort into the query and still terminates pagination', async () => {
+ const res = await callList(`workspaceId=workspace-1&search=report&sortBy=name&sortOrder=asc`)
+
+ expect(res.status).toBe(200)
+ expect((await res.json()).nextCursor).toBeNull()
+ })
+})
+
+describe('POST /api/v2/skills', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockPerformCreateSkill.mockResolvedValue({ success: true, skill: buildSkill() })
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callCreate(VALID_BODY)
+
+ expect(res.status).toBe(404)
+ expect(mockPerformCreateSkill).not.toHaveBeenCalled()
+ })
+
+ it('400s when the body is missing content', async () => {
+ const res = await callCreate({
+ workspaceId: 'workspace-1',
+ name: 'refund-policy',
+ description: 'How to handle refunds',
+ })
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(mockPerformCreateSkill).not.toHaveBeenCalled()
+ })
+
+ it('400s when the name is not kebab-case', async () => {
+ const res = await callCreate({ ...VALID_BODY, name: 'Refund Policy' })
+ expect(res.status).toBe(400)
+ expect(mockPerformCreateSkill).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue({
+ status: 403,
+ code: 'FORBIDDEN',
+ message: 'Access denied',
+ })
+ const res = await callCreate(VALID_BODY)
+ expect(res.status).toBe(403)
+ expect(mockPerformCreateSkill).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callCreate(VALID_BODY)
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('400s when the orchestration rejects a built-in skill name', async () => {
+ mockPerformCreateSkill.mockResolvedValue({
+ success: false,
+ error: 'The skill name "deploy-workflow" is reserved by a built-in skill',
+ errorCode: 'validation',
+ })
+
+ const res = await callCreate({ ...VALID_BODY, name: 'deploy-workflow' })
+ const body = await res.json()
+
+ expect(res.status).toBe(400)
+ expect(body.error.code).toBe('BAD_REQUEST')
+ expect(body.error.message).toContain('built-in')
+ })
+
+ it('409s when the skill name is already taken', async () => {
+ mockPerformCreateSkill.mockResolvedValue({
+ success: false,
+ error: 'The skill name "refund-policy" is unavailable in this workspace',
+ errorCode: 'conflict',
+ })
+ const res = await callCreate(VALID_BODY)
+ expect(res.status).toBe(409)
+ expect((await res.json()).error.code).toBe('CONFLICT')
+ })
+
+ it('creates the skill and returns 201 with the single skill, not the workspace list', async () => {
+ const res = await callCreate(VALID_BODY)
+ const body = await res.json()
+
+ expect(res.status).toBe(201)
+ expect(body.data).toEqual({
+ skill: {
+ id: 'skl_abc123',
+ name: 'refund-policy',
+ description: 'How to handle refunds',
+ content: '# Refund policy\n\nAlways be kind.',
+ readOnly: false,
+ createdAt: '2024-01-01T00:00:00.000Z',
+ updatedAt: '2024-01-02T00:00:00.000Z',
+ },
+ })
+ expect(mockPerformCreateSkill).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workspaceId: 'workspace-1',
+ userId: 'user-1',
+ name: 'refund-policy',
+ description: 'How to handle refunds',
+ content: '# Refund policy',
+ source: 'api',
+ })
+ )
+ })
+})
diff --git a/apps/sim/app/api/v2/skills/route.ts b/apps/sim/app/api/v2/skills/route.ts
new file mode 100644
index 00000000000..1541ca2be7f
--- /dev/null
+++ b/apps/sim/app/api/v2/skills/route.ts
@@ -0,0 +1,116 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { v2CreateSkillContract, v2ListSkillsContract } from '@/lib/api/contracts/v2/skills'
+import { parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { performCreateSkill } from '@/lib/skills/orchestration'
+import { listSkills } from '@/lib/workflows/skills/operations'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CursorList,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+import { toV2Skill, toV2SkillSummary, v2SkillOrchestrationError } from '@/app/api/v2/skills/utils'
+
+const logger = createLogger('V2SkillsAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+/** GET /api/v2/skills — List skills in a workspace, built-ins included. */
+export const GET = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'skills')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2ListSkillsContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ const { workspaceId, search, sortBy, sortOrder } = parsed.data.query
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const skills = await listSkills({ workspaceId, search, sort: { sortBy, sortOrder } })
+
+ // The per-workspace skill set is small and bounded → a single full page.
+ return v2CursorList(skills.map(toV2SkillSummary), null, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Error listing skills`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/** POST /api/v2/skills — Create a skill. */
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'skills')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2CreateSkillContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ const { workspaceId, name, description, content } = parsed.data.body
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const result = await performCreateSkill({
+ workspaceId,
+ userId,
+ name,
+ description,
+ content,
+ source: 'api',
+ request,
+ })
+
+ if (!result.success || !result.skill) {
+ return v2SkillOrchestrationError(result.errorCode, result.error ?? 'Failed to create skill')
+ }
+
+ return v2Data({ skill: toV2Skill(result.skill) }, { rateLimit, status: 201 })
+ } catch (error) {
+ logger.error(`[${requestId}] Error creating skill`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/skills/utils.ts b/apps/sim/app/api/v2/skills/utils.ts
new file mode 100644
index 00000000000..a1cc30ceb02
--- /dev/null
+++ b/apps/sim/app/api/v2/skills/utils.ts
@@ -0,0 +1,48 @@
+import type { skill } from '@sim/db/schema'
+import type { NextResponse } from 'next/server'
+import type { V2Skill, V2SkillSummary } from '@/lib/api/contracts/v2/skills'
+import type { SkillOrchestrationErrorCode } from '@/lib/skills/orchestration'
+import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills'
+import { v2Error } from '@/app/api/v2/lib/response'
+
+/**
+ * Shared serialization + error mapping for the v2 skills surface.
+ */
+
+type SkillRow = typeof skill.$inferSelect
+
+/** List projection — no `content`; skill bodies are fetched per skill. */
+export function toV2SkillSummary(row: SkillRow): V2SkillSummary {
+ return {
+ id: row.id,
+ name: row.name,
+ description: row.description,
+ readOnly: isBuiltinSkillId(row.id),
+ createdAt: row.createdAt.toISOString(),
+ updatedAt: row.updatedAt.toISOString(),
+ }
+}
+
+/** Detail projection — the summary plus the skill body. */
+export function toV2Skill(row: SkillRow): V2Skill {
+ return { ...toV2SkillSummary(row), content: row.content }
+}
+
+/** Renders a skill orchestration failure in the v2 error envelope. */
+export function v2SkillOrchestrationError(
+ errorCode: SkillOrchestrationErrorCode | undefined,
+ message: string
+): NextResponse {
+ switch (errorCode) {
+ case 'validation':
+ return v2Error('BAD_REQUEST', message)
+ case 'forbidden':
+ return v2Error('FORBIDDEN', message)
+ case 'not_found':
+ return v2Error('NOT_FOUND', 'Skill not found')
+ case 'conflict':
+ return v2Error('CONFLICT', message)
+ default:
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+}
diff --git a/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.test.ts
new file mode 100644
index 00000000000..fed9a372b17
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.test.ts
@@ -0,0 +1,192 @@
+/**
+ * @vitest-environment node
+ *
+ * Public v2 cancel-runs — stops workflow/enrichment cell runs, as opposed to
+ * `job/cancel`, which stops an import or delete. The predicate translates to
+ * storage keys before the cancel so an unknown field 400s rather than becoming
+ * a cancel that silently matches nothing.
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockResolveWorkspaceScope,
+ mockCheckAccess,
+ mockCancelRuns,
+ mockPredicateToFilter,
+ mockSignalRowsChanged,
+ mockGateError,
+ TableQueryValidationError,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceScope: vi.fn(),
+ mockCheckAccess: vi.fn(),
+ mockCancelRuns: vi.fn(),
+ mockPredicateToFilter: vi.fn(),
+ mockSignalRowsChanged: vi.fn(),
+ mockGateError: vi.fn(),
+ TableQueryValidationError: class TableQueryValidationError extends Error {},
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceScope: mockResolveWorkspaceScope,
+}))
+
+vi.mock('@/app/api/table/utils', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ checkAccess: mockCheckAccess,
+}))
+
+vi.mock('@/app/api/v2/tables/utils', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ v2BulkPredicateToFilter: mockPredicateToFilter,
+}))
+
+vi.mock('@/lib/table/workflow-columns', () => ({ cancelWorkflowGroupRuns: mockCancelRuns }))
+vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mockSignalRowsChanged }))
+vi.mock('@/lib/table/errors', () => ({ TableQueryValidationError }))
+vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError }))
+
+import { POST } from '@/app/api/v2/tables/[tableId]/cancel-runs/route'
+
+const TABLE = {
+ id: 'table-1',
+ workspaceId: 'ws-1',
+ schema: { columns: [{ id: 'col-1', name: 'status', type: 'string' }] },
+}
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ workspaceId: 'ws-1',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2026-01-01T01:00:00Z'),
+}
+
+function callPost(body: unknown) {
+ const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/cancel-runs', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) })
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceScope.mockResolvedValue(null)
+ mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE })
+ mockCancelRuns.mockResolvedValue(4)
+ mockGateError.mockResolvedValue(null)
+})
+
+describe('POST /api/v2/tables/[tableId]/cancel-runs', () => {
+ it('cancels every run under scope "all" and reports the count', async () => {
+ const res = await callPost({ workspaceId: 'ws-1', scope: 'all' })
+
+ expect(res.status).toBe(200)
+ expect((await res.json()).data).toEqual({ cancelled: 4 })
+ expect(mockCancelRuns).toHaveBeenCalledWith('table-1', undefined, {
+ filter: undefined,
+ excludeRowIds: undefined,
+ })
+ // Cancelling clears the affected cells, so open readers must refetch.
+ expect(mockSignalRowsChanged).toHaveBeenCalledWith('table-1')
+ })
+
+ it('scopes to a single row when asked', async () => {
+ const res = await callPost({ workspaceId: 'ws-1', scope: 'row', rowId: 'row-1' })
+
+ expect(res.status).toBe(200)
+ expect(mockCancelRuns).toHaveBeenCalledWith('table-1', 'row-1', expect.anything())
+ })
+
+ it('translates a name-keyed predicate to the storage-keyed filter', async () => {
+ mockPredicateToFilter.mockReturnValue({ 'col-1': { $eq: 'active' } })
+ const predicate = { all: [{ field: 'status', op: 'eq', value: 'active' }] }
+
+ await callPost({ workspaceId: 'ws-1', scope: 'all', filter: predicate })
+
+ expect(mockPredicateToFilter).toHaveBeenCalledWith(predicate, TABLE.schema)
+ expect(mockCancelRuns).toHaveBeenCalledWith(
+ 'table-1',
+ undefined,
+ expect.objectContaining({ filter: { 'col-1': { $eq: 'active' } } })
+ )
+ })
+
+ it('400s an unresolvable predicate field instead of cancelling nothing', async () => {
+ mockPredicateToFilter.mockImplementation(() => {
+ throw new TableQueryValidationError('Unknown column "nope"')
+ })
+
+ const res = await callPost({
+ workspaceId: 'ws-1',
+ scope: 'all',
+ filter: { all: [{ field: 'nope', op: 'eq', value: 1 }] },
+ })
+
+ expect(res.status).toBe(400)
+ expect(mockCancelRuns).not.toHaveBeenCalled()
+ })
+
+ it('400s scope "row" with no rowId', async () => {
+ const res = await callPost({ workspaceId: 'ws-1', scope: 'row' })
+
+ expect(res.status).toBe(400)
+ expect(mockCancelRuns).not.toHaveBeenCalled()
+ })
+
+ it('400s scope "row" combined with a filter', async () => {
+ const res = await callPost({
+ workspaceId: 'ws-1',
+ scope: 'row',
+ rowId: 'row-1',
+ filter: { all: [{ field: 'status', op: 'eq', value: 'active' }] },
+ })
+
+ expect(res.status).toBe(400)
+ expect(mockCancelRuns).not.toHaveBeenCalled()
+ })
+
+ it('403s a read-only member', async () => {
+ mockCheckAccess.mockResolvedValue({ ok: false, status: 403 })
+
+ const res = await callPost({ workspaceId: 'ws-1', scope: 'all' })
+
+ expect(res.status).toBe(403)
+ expect(mockCancelRuns).not.toHaveBeenCalled()
+ })
+
+ it('404s with the gate off, before any work', async () => {
+ mockGateError.mockResolvedValue(
+ new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), {
+ status: 404,
+ })
+ )
+
+ const res = await callPost({ workspaceId: 'ws-1', scope: 'all' })
+
+ expect(res.status).toBe(404)
+ expect(mockCheckAccess).not.toHaveBeenCalled()
+ })
+
+ it('429s a throttled caller', async () => {
+ mockCheckRateLimit.mockResolvedValue({
+ ...RATE_LIMIT_OK,
+ allowed: false,
+ remaining: 0,
+ retryAfterMs: 1000,
+ })
+
+ const res = await callPost({ workspaceId: 'ws-1', scope: 'all' })
+
+ expect(res.status).toBe(429)
+ expect(mockCancelRuns).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts
new file mode 100644
index 00000000000..69fe9094e67
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts
@@ -0,0 +1,100 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { v2CancelTableRunsContract } from '@/lib/api/contracts/v2/tables'
+import { isZodError, parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import type { Filter, TableSchema } from '@/lib/table'
+import { TableQueryValidationError } from '@/lib/table/errors'
+import { signalTableRowsChanged } from '@/lib/table/events'
+import { cancelWorkflowGroupRuns } from '@/lib/table/workflow-columns'
+import { checkAccess } from '@/app/api/table/utils'
+import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+import { v2BulkPredicateToFilter, v2TableAccessError } from '@/app/api/v2/tables/utils'
+
+const logger = createLogger('V2TableCancelRunsAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+interface TableRouteParams {
+ params: Promise<{ tableId: string }>
+}
+
+/**
+ * POST /api/v2/tables/[tableId]/cancel-runs — Stop in-flight cell runs.
+ *
+ * The counterpart to `POST /columns/run`, and distinct from
+ * `POST /job/cancel`, which stops an import or delete. `scope: 'all'` cancels
+ * every running and pending cell (optionally narrowed by `filter`); `row`
+ * cancels one row's cells.
+ */
+export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-enrichment')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2CancelTableRunsContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId } = parsed.data.params
+ const { workspaceId, scope, rowId, filter, excludeRowIds } = parsed.data.body
+
+ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const access = await checkAccess(tableId, userId, 'write')
+ if (!access.ok) return v2TableAccessError(access)
+
+ if (access.table.workspaceId !== workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ // The public predicate is column-NAME keyed; the runners compile the
+ // storage-keyed legacy filter. Translating up front makes an unknown field
+ // a 400 rather than a cancel that silently matches nothing.
+ let legacyFilter: Filter | undefined
+ if (filter) {
+ legacyFilter = v2BulkPredicateToFilter(filter, access.table.schema as TableSchema)
+ }
+
+ const cancelled = await cancelWorkflowGroupRuns(tableId, scope === 'row' ? rowId : undefined, {
+ filter: legacyFilter,
+ excludeRowIds,
+ })
+
+ // Cancelling clears the affected rows' exec state, so open readers must
+ // refetch to pick up the cleared cells.
+ signalTableRowsChanged(tableId)
+
+ logger.info(`[${requestId}] Cancelled table runs`, { tableId, scope, rowId, cancelled })
+
+ return v2Data({ cancelled }, { rateLimit })
+ } catch (error) {
+ if (isZodError(error)) return v2ValidationError(error)
+ if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message)
+
+ logger.error(`[${requestId}] Error cancelling table runs`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts
new file mode 100644
index 00000000000..a7d6235dca0
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts
@@ -0,0 +1,105 @@
+/**
+ * @vitest-environment node
+ *
+ * v2 column update wiring: the route authenticates, scopes, delegates to the
+ * orchestration function, and maps its failure classes onto the v2 envelope.
+ * The guards themselves are covered in lib/table/orchestration/columns.test.ts.
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockCheckRateLimit, mockResolveWorkspaceScope, mockCheckAccess, mockPerformUpdate } =
+ vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceScope: vi.fn(),
+ mockCheckAccess: vi.fn(),
+ mockPerformUpdate: vi.fn(),
+ }))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceScope: mockResolveWorkspaceScope,
+}))
+
+vi.mock('@/app/api/table/utils', () => ({
+ checkAccess: mockCheckAccess,
+ normalizeColumn: (col: Record) => col,
+}))
+
+vi.mock('@/lib/table', () => ({ addTableColumn: vi.fn(), deleteColumn: vi.fn() }))
+
+vi.mock('@/lib/table/orchestration', () => ({
+ performUpdateTableColumn: mockPerformUpdate,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+import { PATCH } from '@/app/api/v2/tables/[tableId]/columns/route'
+
+const COLUMN = { id: 'col-1', name: 'Status', type: 'text' }
+const TABLE = { id: 'table-1', name: 'Tasks', workspaceId: 'ws-1', schema: { columns: [COLUMN] } }
+
+function patch(updates: Record = { name: 'State' }) {
+ const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/columns', {
+ method: 'PATCH',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ workspaceId: 'ws-1', columnName: 'Status', updates }),
+ })
+ return PATCH(req, { params: Promise.resolve({ tableId: 'table-1' }) })
+}
+
+describe('PATCH /api/v2/tables/[tableId]/columns', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue({
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ workspaceId: 'ws-1',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2026-01-01T01:00:00Z'),
+ })
+ mockResolveWorkspaceScope.mockResolvedValue(null)
+ mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE })
+ mockPerformUpdate.mockResolvedValue({ success: true, table: TABLE })
+ })
+
+ it('delegates to the orchestration function with the resolved table and actor', async () => {
+ const res = await patch()
+
+ expect(res.status).toBe(200)
+ expect((await res.json()).data).toEqual({ columns: [COLUMN] })
+ expect(mockPerformUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({ table: TABLE, columnName: 'Status', userId: 'user-1' })
+ )
+ })
+
+ it.each([
+ ['validation', 400, 'BAD_REQUEST'],
+ ['not_found', 404, 'NOT_FOUND'],
+ ['locked', 423, 'LOCKED'],
+ ])('maps a %s failure to %i', async (errorCode, status, code) => {
+ mockPerformUpdate.mockResolvedValue({ success: false, errorCode, error: 'nope' })
+
+ const res = await patch()
+
+ expect(res.status).toBe(status)
+ expect((await res.json()).error.code).toBe(code)
+ })
+
+ it('does not leak an internal failure message', async () => {
+ mockPerformUpdate.mockResolvedValue({
+ success: false,
+ errorCode: 'internal',
+ error: 'connection string leaked',
+ })
+
+ const res = await patch()
+
+ expect(res.status).toBe(500)
+ expect(await res.text()).not.toContain('connection string')
+ })
+})
diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts
new file mode 100644
index 00000000000..77a3f1f5e1c
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts
@@ -0,0 +1,214 @@
+import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import {
+ v2AddTableColumnContract,
+ v2DeleteTableColumnContract,
+ v2UpdateTableColumnContract,
+} from '@/lib/api/contracts/v2/tables'
+import { isZodError, parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { addTableColumn, deleteColumn } from '@/lib/table'
+import { performUpdateTableColumn } from '@/lib/table/orchestration'
+import { checkAccess, normalizeColumn } from '@/app/api/table/utils'
+import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CaughtOrchestrationError,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+import { v2TableAccessError, v2TableOrchestrationError } from '@/app/api/v2/tables/utils'
+
+const logger = createLogger('V2TableColumnsAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+interface ColumnsRouteParams {
+ params: Promise<{ tableId: string }>
+}
+
+/** POST /api/v2/tables/[tableId]/columns — Add a column to the table schema. */
+export const POST = withRouteHandler(async (request: NextRequest, context: ColumnsRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-columns')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2AddTableColumnContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId } = parsed.data.params
+ const validated = parsed.data.body
+
+ const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const result = await checkAccess(tableId, userId, 'write')
+ if (!result.ok) return v2TableAccessError(result)
+
+ const { table } = result
+ if (table.workspaceId !== validated.workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ const updatedTable = await addTableColumn(tableId, validated.column, requestId)
+
+ recordAudit({
+ workspaceId: validated.workspaceId,
+ actorId: userId,
+ action: AuditAction.TABLE_UPDATED,
+ resourceType: AuditResourceType.TABLE,
+ resourceId: tableId,
+ resourceName: table.name,
+ description: `Added column "${validated.column.name}" to table "${table.name}"`,
+ metadata: { column: validated.column },
+ request,
+ })
+
+ return v2Data({ columns: updatedTable.schema.columns.map(normalizeColumn) }, { rateLimit })
+ } catch (error) {
+ if (isZodError(error)) return v2ValidationError(error)
+
+ const classified = v2CaughtOrchestrationError(error)
+ if (classified) return classified
+
+ logger.error(`[${requestId}] Error adding column to table`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/** PATCH /api/v2/tables/[tableId]/columns — Update a column (rename, type change, constraints). */
+export const PATCH = withRouteHandler(async (request: NextRequest, context: ColumnsRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-columns')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2UpdateTableColumnContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId } = parsed.data.params
+ const validated = parsed.data.body
+
+ const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const result = await checkAccess(tableId, userId, 'write')
+ if (!result.ok) return v2TableAccessError(result)
+
+ const { table } = result
+ if (table.workspaceId !== validated.workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ const outcome = await performUpdateTableColumn({
+ table,
+ columnName: validated.columnName,
+ userId,
+ updates: validated.updates,
+ requestId,
+ request,
+ })
+ if (!outcome.success || !outcome.table) {
+ return v2TableOrchestrationError(outcome, 'Failed to update column')
+ }
+
+ return v2Data({ columns: outcome.table.schema.columns.map(normalizeColumn) }, { rateLimit })
+ } catch (error) {
+ if (isZodError(error)) return v2ValidationError(error)
+ logger.error(`[${requestId}] Error updating column in table`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/** DELETE /api/v2/tables/[tableId]/columns — Delete a column from the table schema. */
+export const DELETE = withRouteHandler(
+ async (request: NextRequest, context: ColumnsRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-columns')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2DeleteTableColumnContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId } = parsed.data.params
+ const validated = parsed.data.body
+
+ const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const result = await checkAccess(tableId, userId, 'write')
+ if (!result.ok) return v2TableAccessError(result)
+
+ const { table } = result
+ if (table.workspaceId !== validated.workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ const updatedTable = await deleteColumn(
+ { tableId, columnName: validated.columnName },
+ requestId
+ )
+
+ recordAudit({
+ workspaceId: validated.workspaceId,
+ actorId: userId,
+ action: AuditAction.TABLE_UPDATED,
+ resourceType: AuditResourceType.TABLE,
+ resourceId: tableId,
+ resourceName: table.name,
+ description: `Deleted column "${validated.columnName}" from table "${table.name}"`,
+ metadata: { columnName: validated.columnName },
+ request,
+ })
+
+ return v2Data({ columns: updatedTable.schema.columns.map(normalizeColumn) }, { rateLimit })
+ } catch (error) {
+ if (isZodError(error)) return v2ValidationError(error)
+
+ const classified = v2CaughtOrchestrationError(error)
+ if (classified) return classified
+
+ logger.error(`[${requestId}] Error deleting column from table`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts
new file mode 100644
index 00000000000..e3dc1b23c0b
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts
@@ -0,0 +1,195 @@
+/**
+ * @vitest-environment node
+ *
+ * Public v2 column run. The public predicate is column-NAME keyed and the
+ * dispatcher compiles a storage-keyed legacy filter, so the route translates
+ * before dispatching — an unknown field must 400 here rather than becoming a
+ * run that silently matches nothing.
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockResolveWorkspaceScope,
+ mockCheckAccess,
+ mockRunWorkflowColumn,
+ mockPredicateToFilter,
+ mockSignalRowsChanged,
+ mockGateError,
+ TableQueryValidationError,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceScope: vi.fn(),
+ mockCheckAccess: vi.fn(),
+ mockRunWorkflowColumn: vi.fn(),
+ mockPredicateToFilter: vi.fn(),
+ mockSignalRowsChanged: vi.fn(),
+ mockGateError: vi.fn(),
+ TableQueryValidationError: class TableQueryValidationError extends Error {},
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceScope: mockResolveWorkspaceScope,
+}))
+
+vi.mock('@/app/api/table/utils', () => ({
+ checkAccess: mockCheckAccess,
+ normalizeColumn: (col: Record) => col,
+ rootErrorMessage: (error: unknown) => String(error),
+ rowWriteErrorResponse: () => null,
+}))
+
+vi.mock('@/app/api/v2/tables/utils', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ v2BulkPredicateToFilter: mockPredicateToFilter,
+}))
+
+vi.mock('@/lib/table/workflow-columns', () => ({ runWorkflowColumn: mockRunWorkflowColumn }))
+vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mockSignalRowsChanged }))
+vi.mock('@/lib/table/errors', () => ({ TableQueryValidationError }))
+vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError }))
+
+import { POST } from '@/app/api/v2/tables/[tableId]/columns/run/route'
+
+const TABLE = {
+ id: 'table-1',
+ workspaceId: 'ws-1',
+ schema: { columns: [{ id: 'col-1', name: 'status', type: 'string' }] },
+}
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ workspaceId: 'ws-1',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2026-01-01T01:00:00Z'),
+}
+
+function callPost(body: unknown) {
+ const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/columns/run', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) })
+}
+
+describe('POST /api/v2/tables/[tableId]/columns/run', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceScope.mockResolvedValue(null)
+ mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE })
+ mockRunWorkflowColumn.mockResolvedValue({ dispatchId: 'dispatch-1' })
+ mockGateError.mockResolvedValue(null)
+ })
+
+ it('dispatches the run and returns the dispatch id', async () => {
+ const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'], rowIds: ['row-1'] })
+
+ expect(res.status).toBe(200)
+ expect((await res.json()).data).toEqual({ dispatchId: 'dispatch-1' })
+ expect(mockRunWorkflowColumn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ tableId: 'table-1',
+ workspaceId: 'ws-1',
+ groupIds: ['group-1'],
+ rowIds: ['row-1'],
+ mode: 'all',
+ filter: undefined,
+ triggeredByUserId: 'user-1',
+ })
+ )
+ // The bulk clear is a row change even when the dispatch is a no-op.
+ expect(mockSignalRowsChanged).toHaveBeenCalledWith('table-1')
+ })
+
+ it('translates a name-keyed predicate to the storage-keyed filter the dispatcher walks', async () => {
+ mockPredicateToFilter.mockReturnValue({ 'col-1': { $eq: 'active' } })
+ const predicate = { all: [{ field: 'status', op: 'eq', value: 'active' }] }
+
+ const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'], filter: predicate })
+
+ expect(res.status).toBe(200)
+ expect(mockPredicateToFilter).toHaveBeenCalledWith(predicate, TABLE.schema)
+ expect(mockRunWorkflowColumn).toHaveBeenCalledWith(
+ expect.objectContaining({ filter: { 'col-1': { $eq: 'active' } } })
+ )
+ })
+
+ it('400s an unresolvable predicate field instead of dispatching a no-match run', async () => {
+ mockPredicateToFilter.mockImplementation(() => {
+ throw new TableQueryValidationError('Unknown column "nope"')
+ })
+
+ const res = await callPost({
+ workspaceId: 'ws-1',
+ groupIds: ['group-1'],
+ filter: { all: [{ field: 'nope', op: 'eq', value: 1 }] },
+ })
+
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.message).toBe('Unknown column "nope"')
+ expect(mockRunWorkflowColumn).not.toHaveBeenCalled()
+ })
+
+ it('400s rowIds and filter together', async () => {
+ const res = await callPost({
+ workspaceId: 'ws-1',
+ groupIds: ['group-1'],
+ rowIds: ['row-1'],
+ filter: { all: [{ field: 'status', op: 'eq', value: 'active' }] },
+ })
+
+ expect(res.status).toBe(400)
+ expect(mockRunWorkflowColumn).not.toHaveBeenCalled()
+ })
+
+ it('400s an empty groupIds list', async () => {
+ const res = await callPost({ workspaceId: 'ws-1', groupIds: [] })
+
+ expect(res.status).toBe(400)
+ expect(mockRunWorkflowColumn).not.toHaveBeenCalled()
+ })
+
+ it('403s a read-only member', async () => {
+ mockCheckAccess.mockResolvedValue({ ok: false, status: 403 })
+
+ const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'] })
+
+ expect(res.status).toBe(403)
+ expect(mockRunWorkflowColumn).not.toHaveBeenCalled()
+ })
+
+ it('404s with the gate off, before any work', async () => {
+ mockGateError.mockResolvedValue(
+ new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), {
+ status: 404,
+ })
+ )
+
+ const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'] })
+
+ expect(res.status).toBe(404)
+ expect(mockCheckAccess).not.toHaveBeenCalled()
+ expect(mockRunWorkflowColumn).not.toHaveBeenCalled()
+ })
+
+ it('429s a throttled caller', async () => {
+ mockCheckRateLimit.mockResolvedValue({
+ ...RATE_LIMIT_OK,
+ allowed: false,
+ remaining: 0,
+ retryAfterMs: 1000,
+ })
+
+ const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'] })
+
+ expect(res.status).toBe(429)
+ expect(mockRunWorkflowColumn).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts
new file mode 100644
index 00000000000..f534f57f5fd
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts
@@ -0,0 +1,118 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { v2RunTableColumnContract } from '@/lib/api/contracts/v2/tables'
+import { isZodError, parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import type { Filter, TableSchema } from '@/lib/table'
+import { TableQueryValidationError } from '@/lib/table/errors'
+import { signalTableRowsChanged } from '@/lib/table/events'
+import { runWorkflowColumn } from '@/lib/table/workflow-columns'
+import { checkAccess } from '@/app/api/table/utils'
+import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CaughtOrchestrationError,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+import {
+ v2BulkPredicateToFilter,
+ v2TableAccessError,
+ v2TableLockError,
+} from '@/app/api/v2/tables/utils'
+
+const logger = createLogger('V2TableRunColumnAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+interface TableRouteParams {
+ params: Promise<{ tableId: string }>
+}
+
+/**
+ * POST /api/v2/tables/[tableId]/columns/run — Run workflow/enrichment groups.
+ *
+ * Asynchronous: the response acknowledges the dispatch, not the results. The
+ * dispatcher walks the scoped rows and writes cells as runs land, so callers
+ * poll the row endpoints. `dispatchId` is `null` where no background runner is
+ * configured and cells execute inline.
+ */
+export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-enrichment')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2RunTableColumnContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId } = parsed.data.params
+ const { workspaceId, groupIds, runMode, rowIds, filter, excludeRowIds, limit } =
+ parsed.data.body
+
+ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const access = await checkAccess(tableId, userId, 'write')
+ if (!access.ok) return v2TableAccessError(access)
+
+ if (access.table.workspaceId !== workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ // The public predicate is column-NAME keyed; the dispatcher compiles the
+ // storage-keyed legacy filter. Translating up front also makes an unknown
+ // field a 400 here rather than a dispatch that silently matches nothing.
+ let legacyFilter: Filter | undefined
+ if (filter) {
+ legacyFilter = v2BulkPredicateToFilter(filter, access.table.schema as TableSchema)
+ }
+
+ const { dispatchId } = await runWorkflowColumn({
+ tableId,
+ workspaceId,
+ groupIds,
+ mode: runMode,
+ rowIds,
+ filter: legacyFilter,
+ excludeRowIds,
+ limit,
+ requestId,
+ triggeredByUserId: userId,
+ })
+
+ // Starting a run clears the target groups' cells to pending — a row change
+ // open readers must pick up.
+ signalTableRowsChanged(tableId)
+
+ return v2Data({ dispatchId }, { rateLimit })
+ } catch (error) {
+ if (isZodError(error)) return v2ValidationError(error)
+ if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message)
+
+ const lockError = v2TableLockError(error)
+ if (lockError) return lockError
+
+ const classified = v2CaughtOrchestrationError(error)
+ if (classified) return classified
+
+ logger.error(`[${requestId}] Error running table columns`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts b/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts
new file mode 100644
index 00000000000..63bf970f013
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts
@@ -0,0 +1,67 @@
+import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { v2CreateTableExportContract } from '@/lib/api/contracts/v2/tables'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ createTableExportResource,
+ toV2TableExport,
+} from '@/lib/table/orchestration/export-resource'
+import { checkAccess } from '@/app/api/table/utils'
+import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CaughtOrchestrationError,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2TableExportsAPI')
+
+interface TableRouteParams {
+ params: Promise<{ tableId: string }>
+}
+
+export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-export')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(v2CreateTableExportContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+ const { workspaceId, format } = parsed.data.body
+ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+ const access = await checkAccess(parsed.data.params.tableId, userId, 'read')
+ if (!access.ok || access.table.workspaceId !== workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+ const record = await createTableExportResource({ table: access.table, format })
+ recordAudit({
+ workspaceId,
+ actorId: userId,
+ action: AuditAction.TABLE_EXPORTED,
+ resourceType: AuditResourceType.TABLE,
+ resourceId: access.table.id,
+ resourceName: access.table.name,
+ description: `Exported table "${access.table.name}" as ${format.toUpperCase()}`,
+ metadata: { format, rowCount: access.table.rowCount },
+ request,
+ })
+ return v2Data(toV2TableExport(record, true), { rateLimit, status: 201 })
+ } catch (error) {
+ const classified = v2CaughtOrchestrationError(error)
+ if (classified) return classified
+ logger.error('Failed to create table export', { error: getErrorMessage(error) })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts
new file mode 100644
index 00000000000..0a847cabcac
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts
@@ -0,0 +1,437 @@
+/**
+ * @vitest-environment node
+ *
+ * Public v2 workflow-group listing — a read-only projection of the table's
+ * schema, exposed so a caller can discover the group ids the run endpoints
+ * take.
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockResolveWorkspaceScope,
+ mockCheckAccess,
+ mockGateError,
+ mockAddWorkflowGroup,
+ mockUpdateWorkflowGroup,
+ mockDeleteWorkflowGroup,
+ mockGetActiveWorkflowContext,
+ mockSignalSchemaChanged,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceScope: vi.fn(),
+ mockCheckAccess: vi.fn(),
+ mockGateError: vi.fn(),
+ mockAddWorkflowGroup: vi.fn(),
+ mockUpdateWorkflowGroup: vi.fn(),
+ mockDeleteWorkflowGroup: vi.fn(),
+ mockGetActiveWorkflowContext: vi.fn(),
+ mockSignalSchemaChanged: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceScope: mockResolveWorkspaceScope,
+}))
+
+vi.mock('@/app/api/table/utils', () => ({
+ checkAccess: mockCheckAccess,
+ normalizeColumn: (col: Record) => col,
+ rootErrorMessage: (error: unknown) => String(error),
+ rowWriteErrorResponse: () => null,
+}))
+
+vi.mock('@/lib/table/workflow-groups/service', () => ({
+ addWorkflowGroup: mockAddWorkflowGroup,
+ updateWorkflowGroup: mockUpdateWorkflowGroup,
+ deleteWorkflowGroup: mockDeleteWorkflowGroup,
+}))
+
+vi.mock('@sim/platform-authz/workflow', () => ({
+ getActiveWorkflowContext: mockGetActiveWorkflowContext,
+}))
+
+vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mockSignalSchemaChanged }))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError }))
+
+import { DELETE, GET, PATCH, POST } from '@/app/api/v2/tables/[tableId]/groups/route'
+
+const GROUP = {
+ id: 'group-1',
+ workflowId: 'wf-1',
+ name: 'Enrich',
+ outputs: [{ blockId: 'blk-1', path: 'content', columnName: 'summary' }],
+}
+const TABLE = {
+ id: 'table-1',
+ workspaceId: 'ws-1',
+ schema: { columns: [], workflowGroups: [GROUP] },
+}
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ workspaceId: 'ws-1',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2026-01-01T01:00:00Z'),
+}
+
+function callGet() {
+ const req = new NextRequest(
+ 'http://localhost:3000/api/v2/tables/table-1/groups?workspaceId=ws-1',
+ { method: 'GET' }
+ )
+ return GET(req, { params: Promise.resolve({ tableId: 'table-1' }) })
+}
+
+describe('GET /api/v2/tables/[tableId]/groups', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceScope.mockResolvedValue(null)
+ mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE })
+ mockGateError.mockResolvedValue(null)
+ })
+
+ it('returns the schema groups as one full page', async () => {
+ const res = await callGet()
+
+ expect(res.status).toBe(200)
+ expect(await res.json()).toEqual({ data: [GROUP], nextCursor: null })
+ })
+
+ it('returns an empty page for a table with no groups', async () => {
+ mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, schema: { columns: [] } } })
+
+ const res = await callGet()
+
+ expect(res.status).toBe(200)
+ expect(await res.json()).toEqual({ data: [], nextCursor: null })
+ })
+
+ it('masks a permission failure as 404 so table existence never leaks', async () => {
+ mockCheckAccess.mockResolvedValue({ ok: false, status: 403 })
+
+ const res = await callGet()
+
+ expect(res.status).toBe(404)
+ })
+
+ it('400s a request with no workspaceId', async () => {
+ const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/groups', {
+ method: 'GET',
+ })
+ const res = await GET(req, { params: Promise.resolve({ tableId: 'table-1' }) })
+
+ expect(res.status).toBe(400)
+ expect(mockCheckAccess).not.toHaveBeenCalled()
+ })
+
+ it('404s with the gate off, before any work', async () => {
+ mockGateError.mockResolvedValue(
+ new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), {
+ status: 404,
+ })
+ )
+
+ const res = await callGet()
+
+ expect(res.status).toBe(404)
+ expect(mockCheckAccess).not.toHaveBeenCalled()
+ })
+
+ it('429s a throttled caller', async () => {
+ mockCheckRateLimit.mockResolvedValue({
+ ...RATE_LIMIT_OK,
+ allowed: false,
+ remaining: 0,
+ retryAfterMs: 1000,
+ })
+
+ const res = await callGet()
+
+ expect(res.status).toBe(429)
+ expect(mockCheckAccess).not.toHaveBeenCalled()
+ })
+})
+
+const ADD_BODY = {
+ workspaceId: 'ws-1',
+ group: {
+ workflowId: 'wf-1',
+ outputs: [{ blockId: 'blk-1', path: 'content', columnName: 'summary' }],
+ },
+ outputColumns: [{ name: 'summary', type: 'string' }],
+}
+
+const UPDATED_TABLE = {
+ id: 'table-1',
+ workspaceId: 'ws-1',
+ schema: { columns: [{ name: 'summary', type: 'string' }], workflowGroups: [GROUP] },
+}
+
+function callWrite(method: 'POST' | 'PATCH' | 'DELETE', body: unknown) {
+ const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/groups', {
+ method,
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ const handler = method === 'POST' ? POST : method === 'PATCH' ? PATCH : DELETE
+ return handler(req, { params: Promise.resolve({ tableId: 'table-1' }) })
+}
+
+describe('POST /api/v2/tables/[tableId]/groups', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceScope.mockResolvedValue(null)
+ mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE })
+ mockGateError.mockResolvedValue(null)
+ mockGetActiveWorkflowContext.mockResolvedValue({ workspaceId: 'ws-1' })
+ // Echo back the id the route generated, as the real service does.
+ mockAddWorkflowGroup.mockImplementation(async (data: { group: { id: string } }) => ({
+ ...UPDATED_TABLE,
+ schema: {
+ ...UPDATED_TABLE.schema,
+ workflowGroups: [{ ...GROUP, id: data.group.id }],
+ },
+ }))
+ })
+
+ it('creates the group and its columns, returning both', async () => {
+ const res = await callWrite('POST', ADD_BODY)
+
+ expect(res.status).toBe(201)
+ const body = await res.json()
+ expect(body.data.group).toMatchObject({ workflowId: 'wf-1', name: 'Enrich' })
+ expect(body.data.columns).toEqual([{ name: 'summary', type: 'string' }])
+ expect(mockSignalSchemaChanged).toHaveBeenCalledWith('table-1')
+ })
+
+ it('500s rather than emitting a body without the group it claims to have written', async () => {
+ // Write reports success but the group is absent — an internal inconsistency
+ // must not surface as a 200 with `group: undefined`.
+ mockAddWorkflowGroup.mockResolvedValue({
+ ...UPDATED_TABLE,
+ schema: { columns: [], workflowGroups: [] },
+ })
+
+ const res = await callWrite('POST', ADD_BODY)
+
+ expect(res.status).toBe(500)
+ expect((await res.json()).error.code).toBe('INTERNAL_ERROR')
+ })
+
+ it('server-generates the group id and stamps it onto the output columns', async () => {
+ await callWrite('POST', ADD_BODY)
+
+ const call = mockAddWorkflowGroup.mock.calls[0][0]
+ expect(call.group.id).toEqual(expect.any(String))
+ expect(call.group.id).not.toBe('')
+ // The caller never supplies workflowGroupId — it is derived from the group.
+ expect(call.outputColumns[0].workflowGroupId).toBe(call.group.id)
+ })
+
+ it('defaults autoRun to false so one POST cannot fan out a metered backfill', async () => {
+ await callWrite('POST', ADD_BODY)
+ expect(mockAddWorkflowGroup.mock.calls[0][0].autoRun).toBe(false)
+ })
+
+ it('rejects a workflow from another workspace before persisting it', async () => {
+ mockGetActiveWorkflowContext.mockResolvedValue({ workspaceId: 'ws-other' })
+
+ const res = await callWrite('POST', ADD_BODY)
+
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.message).toContain('Workflow not found')
+ expect(mockAddWorkflowGroup).not.toHaveBeenCalled()
+ })
+
+ it('rejects an output column that no group output feeds', async () => {
+ const res = await callWrite('POST', {
+ ...ADD_BODY,
+ outputColumns: [{ name: 'summry', type: 'string' }],
+ })
+
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.message).toContain('summry')
+ expect(mockAddWorkflowGroup).not.toHaveBeenCalled()
+ })
+
+ it('400s an enrichment group with no enrichmentId', async () => {
+ const res = await callWrite('POST', {
+ ...ADD_BODY,
+ group: { ...ADD_BODY.group, workflowId: '', type: 'enrichment' },
+ })
+
+ expect(res.status).toBe(400)
+ expect(mockAddWorkflowGroup).not.toHaveBeenCalled()
+ })
+
+ it('400s a workflow group with no workflowId', async () => {
+ const res = await callWrite('POST', {
+ ...ADD_BODY,
+ group: { ...ADD_BODY.group, workflowId: '' },
+ })
+
+ expect(res.status).toBe(400)
+ expect(mockAddWorkflowGroup).not.toHaveBeenCalled()
+ })
+
+ it('masks a permission failure as 404', async () => {
+ mockCheckAccess.mockResolvedValue({ ok: false, status: 403 })
+
+ const res = await callWrite('POST', ADD_BODY)
+
+ expect(res.status).toBe(404)
+ expect(mockAddWorkflowGroup).not.toHaveBeenCalled()
+ })
+
+ it('404s with the gate off, before any work', async () => {
+ mockGateError.mockResolvedValue(
+ new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), {
+ status: 404,
+ })
+ )
+
+ const res = await callWrite('POST', ADD_BODY)
+
+ expect(res.status).toBe(404)
+ expect(mockAddWorkflowGroup).not.toHaveBeenCalled()
+ })
+
+ it('429s a throttled caller', async () => {
+ mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT_OK, allowed: false, retryAfterMs: 1000 })
+
+ const res = await callWrite('POST', ADD_BODY)
+
+ expect(res.status).toBe(429)
+ expect(mockAddWorkflowGroup).not.toHaveBeenCalled()
+ })
+
+ it('surfaces a duplicate-column failure as 400, not 500', async () => {
+ mockAddWorkflowGroup.mockRejectedValue(new Error('Column "summary" already exists'))
+
+ const res = await callWrite('POST', ADD_BODY)
+
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.message).toContain('already exists')
+ })
+})
+
+describe('PATCH /api/v2/tables/[tableId]/groups', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceScope.mockResolvedValue(null)
+ mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE })
+ mockGateError.mockResolvedValue(null)
+ mockGetActiveWorkflowContext.mockResolvedValue({ workspaceId: 'ws-1' })
+ mockUpdateWorkflowGroup.mockResolvedValue(UPDATED_TABLE)
+ })
+
+ it('updates the group and returns it with the resulting columns', async () => {
+ const res = await callWrite('PATCH', {
+ workspaceId: 'ws-1',
+ groupId: 'group-1',
+ name: 'Renamed',
+ })
+
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.data.group).toEqual(GROUP)
+ expect(body.data.columns).toEqual([{ name: 'summary', type: 'string' }])
+ expect(mockUpdateWorkflowGroup).toHaveBeenCalledWith(
+ expect.objectContaining({ tableId: 'table-1', groupId: 'group-1', name: 'Renamed' }),
+ expect.any(String)
+ )
+ })
+
+ it('re-checks workspace containment when the group is re-pointed', async () => {
+ mockGetActiveWorkflowContext.mockResolvedValue({ workspaceId: 'ws-other' })
+
+ const res = await callWrite('PATCH', {
+ workspaceId: 'ws-1',
+ groupId: 'group-1',
+ workflowId: 'wf-elsewhere',
+ })
+
+ expect(res.status).toBe(400)
+ expect(mockUpdateWorkflowGroup).not.toHaveBeenCalled()
+ })
+
+ it('stamps the group id onto any newly added output columns', async () => {
+ await callWrite('PATCH', {
+ workspaceId: 'ws-1',
+ groupId: 'group-1',
+ newOutputColumns: [{ name: 'score', type: 'number' }],
+ })
+
+ expect(mockUpdateWorkflowGroup.mock.calls[0][0].newOutputColumns[0].workflowGroupId).toBe(
+ 'group-1'
+ )
+ })
+
+ it('masks a permission failure as 404', async () => {
+ mockCheckAccess.mockResolvedValue({ ok: false, status: 403 })
+
+ const res = await callWrite('PATCH', { workspaceId: 'ws-1', groupId: 'group-1' })
+
+ expect(res.status).toBe(404)
+ expect(mockUpdateWorkflowGroup).not.toHaveBeenCalled()
+ })
+
+ it('404s an unknown group rather than reporting a generic failure', async () => {
+ mockUpdateWorkflowGroup.mockRejectedValue(new Error('Workflow group not found'))
+
+ const res = await callWrite('PATCH', { workspaceId: 'ws-1', groupId: 'nope' })
+
+ expect(res.status).toBe(404)
+ })
+})
+
+describe('DELETE /api/v2/tables/[tableId]/groups', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceScope.mockResolvedValue(null)
+ mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE })
+ mockGateError.mockResolvedValue(null)
+ mockDeleteWorkflowGroup.mockResolvedValue({
+ ...UPDATED_TABLE,
+ schema: { columns: [], workflowGroups: [] },
+ })
+ })
+
+ it('deletes the group and reports the surviving columns', async () => {
+ const res = await callWrite('DELETE', { workspaceId: 'ws-1', groupId: 'group-1' })
+
+ expect(res.status).toBe(200)
+ // The group's columns go with it — the caller sees what is left, not a bare ack.
+ expect(await res.json()).toEqual({ data: { id: 'group-1', deleted: true, columns: [] } })
+ expect(mockDeleteWorkflowGroup).toHaveBeenCalledWith(
+ { tableId: 'table-1', groupId: 'group-1' },
+ expect.any(String)
+ )
+ })
+
+ it('masks a permission failure as 404', async () => {
+ mockCheckAccess.mockResolvedValue({ ok: false, status: 403 })
+
+ const res = await callWrite('DELETE', { workspaceId: 'ws-1', groupId: 'group-1' })
+
+ expect(res.status).toBe(404)
+ expect(mockDeleteWorkflowGroup).not.toHaveBeenCalled()
+ })
+
+ it('400s a body with no groupId', async () => {
+ const res = await callWrite('DELETE', { workspaceId: 'ws-1' })
+
+ expect(res.status).toBe(400)
+ expect(mockDeleteWorkflowGroup).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts
new file mode 100644
index 00000000000..2d96bf9149a
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts
@@ -0,0 +1,367 @@
+import { createLogger } from '@sim/logger'
+import { getActiveWorkflowContext } from '@sim/platform-authz/workflow'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import type { NextRequest } from 'next/server'
+import {
+ v2AddWorkflowGroupContract,
+ v2DeleteWorkflowGroupContract,
+ v2ListWorkflowGroupsContract,
+ v2UpdateWorkflowGroupContract,
+} from '@/lib/api/contracts/v2/tables'
+import { parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import type { TableDefinition, TableSchema } from '@/lib/table'
+import { signalTableSchemaChanged } from '@/lib/table/events'
+import {
+ addWorkflowGroup,
+ deleteWorkflowGroup,
+ updateWorkflowGroup,
+} from '@/lib/table/workflow-groups/service'
+import { checkAccess, normalizeColumn } from '@/app/api/table/utils'
+import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CursorList,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+import { v2TableLockError } from '@/app/api/v2/tables/utils'
+
+const logger = createLogger('V2TableGroupsAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+interface TableRouteParams {
+ params: Promise<{ tableId: string }>
+}
+
+/**
+ * GET /api/v2/tables/[tableId]/groups — The table's workflow/enrichment groups.
+ *
+ * Read-only: groups are authored in the workflow builder, and the public
+ * surface exposes them so a caller can discover the `groupIds` the run
+ * endpoints take. Groups live on the table's schema, so this is a projection of
+ * the already-loaded definition rather than a second query, and the set is
+ * bounded per table — one full page, `nextCursor` always `null`.
+ */
+export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-groups')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2ListWorkflowGroupsContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+
+ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const result = await checkAccess(tableId, userId, 'read')
+ // Mask not-authorized and not-found alike so cross-workspace existence never leaks.
+ if (!result.ok || result.table.workspaceId !== workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ const groups = (result.table.schema as TableSchema).workflowGroups ?? []
+
+ return v2CursorList(groups, null, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Error listing workflow groups`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/**
+ * Renders a group-service failure in the v2 envelope. The service signals
+ * through thrown `Error` messages rather than classified codes, so the string
+ * matching mirrors the first-party mapper — the two surfaces must agree on
+ * which failures are the caller's fault.
+ */
+function groupMutationError(error: unknown, requestId: string, fallback: string) {
+ const lockError = v2TableLockError(error)
+ if (lockError) return lockError
+
+ if (error instanceof Error) {
+ const message = error.message
+ if (message === 'Table not found' || message.includes('not found')) {
+ return v2Error('NOT_FOUND', message)
+ }
+ if (
+ message.includes('Schema validation') ||
+ message.includes('Missing column definition') ||
+ message.includes('already exists') ||
+ message.includes('exceed')
+ ) {
+ return v2Error('BAD_REQUEST', message)
+ }
+ }
+
+ logger.error(`[${requestId}] ${fallback}`, { error: getErrorMessage(error, 'Unknown error') })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+}
+
+/**
+ * A group persists a `workflowId` that its runs later execute. Without this the
+ * table becomes a way to invoke workflows the API key cannot otherwise reach,
+ * so containment is asserted before the id is stored — on create and on any
+ * update that re-points the group.
+ */
+async function assertWorkflowInWorkspace(workflowId: string, workspaceId: string) {
+ const context = await getActiveWorkflowContext(workflowId)
+ if (!context || context.workspaceId !== workspaceId) {
+ return v2Error('BAD_REQUEST', 'Workflow not found in this workspace')
+ }
+ return null
+}
+
+/**
+ * `{ group, columns }` for the group a mutation touched.
+ *
+ * Throws when the write reports success but the group is absent from the
+ * returned schema. The contract declares `group` as present, so emitting
+ * `undefined` there would ship a body no client can parse while reporting 200 —
+ * an internal inconsistency is worth a 500, not a malformed success.
+ */
+function groupResponse(table: TableDefinition, groupId: string) {
+ const schema = table.schema as TableSchema
+ const group = (schema.workflowGroups ?? []).find((candidate) => candidate.id === groupId)
+ if (!group) {
+ throw new Error(`Workflow group ${groupId} missing from the table after a successful write`)
+ }
+ return { group, columns: schema.columns.map(normalizeColumn) }
+}
+
+/**
+ * POST /api/v2/tables/[tableId]/groups — Bind a workflow or enrichment to the
+ * table and create the columns its runs populate, in one call.
+ */
+export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-groups')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2AddWorkflowGroupContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId } = parsed.data.params
+ const validated = parsed.data.body
+
+ const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const result = await checkAccess(tableId, userId, 'write')
+ if (!result.ok || result.table.workspaceId !== validated.workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ if (validated.group.workflowId) {
+ const workflowError = await assertWorkflowInWorkspace(
+ validated.group.workflowId,
+ result.table.workspaceId
+ )
+ if (workflowError) return workflowError
+ }
+
+ /**
+ * `outputs` and `outputColumns` are two arrays joined by column name, so a
+ * typo in either silently creates a column nothing feeds. The first-party
+ * client builds both from one picker and can't desync; a public caller can,
+ * so the mismatch is rejected rather than persisted.
+ */
+ const outputNames = new Set(validated.group.outputs.map((output) => output.columnName))
+ const orphan = validated.outputColumns.find((column) => !outputNames.has(column.name))
+ if (orphan) {
+ return v2Error(
+ 'BAD_REQUEST',
+ `outputColumns entry "${orphan.name}" has no matching group.outputs[].columnName`
+ )
+ }
+
+ const groupId = validated.group.id ?? generateId()
+
+ const updatedTable = await addWorkflowGroup(
+ {
+ tableId,
+ group: { ...validated.group, id: groupId },
+ // Stamped from the resolved group rather than trusted from the caller.
+ outputColumns: validated.outputColumns.map((column) => ({
+ ...column,
+ workflowGroupId: groupId,
+ })),
+ autoRun: validated.autoRun,
+ actorUserId: userId,
+ },
+ requestId
+ )
+
+ signalTableSchemaChanged(tableId)
+
+ return v2Data(groupResponse(updatedTable, groupId), { rateLimit, status: 201 })
+ } catch (error) {
+ return groupMutationError(error, requestId, 'Failed to add workflow group')
+ }
+})
+
+/**
+ * PATCH /api/v2/tables/[tableId]/groups — Restructure a group: re-point it,
+ * add or remove outputs, or change how its runs are scheduled.
+ *
+ * Removing an output **deletes that column and its values** — the same
+ * behavior as `DELETE /columns` on a bound column. There is no detach.
+ */
+export const PATCH = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-groups')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2UpdateWorkflowGroupContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId } = parsed.data.params
+ const validated = parsed.data.body
+
+ const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const result = await checkAccess(tableId, userId, 'write')
+ if (!result.ok || result.table.workspaceId !== validated.workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ if (validated.workflowId !== undefined) {
+ const workflowError = await assertWorkflowInWorkspace(
+ validated.workflowId,
+ result.table.workspaceId
+ )
+ if (workflowError) return workflowError
+ }
+
+ const updatedTable = await updateWorkflowGroup(
+ {
+ tableId,
+ groupId: validated.groupId,
+ actorUserId: userId,
+ ...(validated.workflowId !== undefined ? { workflowId: validated.workflowId } : {}),
+ ...(validated.name !== undefined ? { name: validated.name } : {}),
+ ...(validated.dependencies !== undefined ? { dependencies: validated.dependencies } : {}),
+ ...(validated.outputs !== undefined ? { outputs: validated.outputs } : {}),
+ ...(validated.newOutputColumns !== undefined
+ ? {
+ newOutputColumns: validated.newOutputColumns.map((column) => ({
+ ...column,
+ workflowGroupId: validated.groupId,
+ })),
+ }
+ : {}),
+ ...(validated.mappingUpdates !== undefined
+ ? { mappingUpdates: validated.mappingUpdates }
+ : {}),
+ ...(validated.inputMappings !== undefined
+ ? { inputMappings: validated.inputMappings }
+ : {}),
+ ...(validated.deploymentMode !== undefined
+ ? { deploymentMode: validated.deploymentMode }
+ : {}),
+ ...(validated.type !== undefined ? { type: validated.type } : {}),
+ ...(validated.autoRun !== undefined ? { autoRun: validated.autoRun } : {}),
+ },
+ requestId
+ )
+
+ signalTableSchemaChanged(tableId)
+
+ return v2Data(groupResponse(updatedTable, validated.groupId), { rateLimit })
+ } catch (error) {
+ return groupMutationError(error, requestId, 'Failed to update workflow group')
+ }
+})
+
+/**
+ * DELETE /api/v2/tables/[tableId]/groups — Remove a group **and every column it
+ * fed**, along with their values. The surviving column list comes back so a
+ * caller does not have to re-read the table to see what is left.
+ */
+export const DELETE = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-groups')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2DeleteWorkflowGroupContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId } = parsed.data.params
+ const validated = parsed.data.body
+
+ const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const result = await checkAccess(tableId, userId, 'write')
+ if (!result.ok || result.table.workspaceId !== validated.workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ const updatedTable = await deleteWorkflowGroup(
+ { tableId, groupId: validated.groupId },
+ requestId
+ )
+
+ signalTableSchemaChanged(tableId)
+
+ return v2Data(
+ {
+ id: validated.groupId,
+ deleted: true as const,
+ columns: (updatedTable.schema as TableSchema).columns.map(normalizeColumn),
+ },
+ { rateLimit }
+ )
+ } catch (error) {
+ return groupMutationError(error, requestId, 'Failed to delete workflow group')
+ }
+})
diff --git a/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts
index 37d8e774d8b..902b8b3bac3 100644
--- a/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts
+++ b/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts
@@ -2,44 +2,40 @@
* @vitest-environment node
*
* Public v2 query POST: typed predicate name→id translation, bounded-default vs
- * explicit-unbounded limit, cursor validation, workspace scoping,
- * and name-keyed row output.
+ * explicit-unbounded limit, cursor validation, workspace scoping, and
+ * name-keyed row output in the `{ data, nextCursor }` envelope.
*/
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TableDefinition } from '@/lib/table/types'
-const { mockCheckAccess, mockQueryRows, mockCheckRateLimit, mockCheckWorkspaceScope, mockGate } =
- vi.hoisted(() => ({
- mockCheckAccess: vi.fn(),
- mockQueryRows: vi.fn(),
- mockCheckRateLimit: vi.fn(),
- mockCheckWorkspaceScope: vi.fn(),
- mockGate: vi.fn(),
- }))
-
-vi.mock('@/app/api/v1/middleware', async () => {
- const { NextResponse } = await import('next/server')
- return {
- checkRateLimit: mockCheckRateLimit,
- checkWorkspaceScope: mockCheckWorkspaceScope,
- createRateLimitResponse: (r: { error?: string }) =>
- NextResponse.json(
- { error: r.error ?? 'Rate limit exceeded' },
- { status: r.error ? 401 : 429 }
- ),
- }
-})
+const {
+ mockCheckAccess,
+ mockQueryRows,
+ mockCheckRateLimit,
+ mockResolveWorkspaceScope,
+ mockIsFeatureEnabled,
+ mockGetWorkspaceOrganizationId,
+} = vi.hoisted(() => ({
+ mockCheckAccess: vi.fn(),
+ mockQueryRows: vi.fn(),
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceScope: vi.fn(),
+ mockIsFeatureEnabled: vi.fn(),
+ mockGetWorkspaceOrganizationId: vi.fn(),
+}))
-vi.mock('@/app/api/table/utils', async () => {
- const { NextResponse } = await import('next/server')
- return {
- checkAccess: mockCheckAccess,
- accessError: (result: { status: number }) =>
- NextResponse.json({ error: 'Access denied' }, { status: result.status }),
- tablesV2GateError: mockGate,
- }
-})
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceScope: mockResolveWorkspaceScope,
+}))
+
+vi.mock('@/app/api/table/utils', () => ({
+ checkAccess: mockCheckAccess,
+ normalizeColumn: (col: Record) => col,
+ rootErrorMessage: (error: unknown) => String(error),
+ rowWriteErrorResponse: () => null,
+}))
vi.mock('@/lib/table', async () => {
const columnKeys = await import('@/lib/table/column-keys')
@@ -48,9 +44,32 @@ vi.mock('@/lib/table', async () => {
vi.mock('@/lib/table/rows/service', () => ({ queryRows: mockQueryRows }))
+vi.mock('@/lib/core/config/feature-flags', () => ({
+ isFeatureEnabled: mockIsFeatureEnabled,
+}))
+
+vi.mock('@/lib/workspaces/utils', () => ({
+ getWorkspaceOrganizationId: mockGetWorkspaceOrganizationId,
+}))
+
import { encodeCursor } from '@/lib/table/rows/cursor'
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
import { POST } from '@/app/api/v2/tables/[tableId]/query/route'
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ workspaceId: 'workspace-1',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+}
+
function buildTable(): TableDefinition {
return {
id: 'tbl_1',
@@ -95,50 +114,24 @@ function callQuery(body: Record) {
describe('POST /api/v2/tables/[tableId]/query', () => {
beforeEach(() => {
vi.clearAllMocks()
- mockCheckRateLimit.mockResolvedValue({
- allowed: true,
- userId: 'user-1',
- keyType: 'workspace',
- workspaceId: 'workspace-1',
- })
- mockCheckWorkspaceScope.mockResolvedValue(null)
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceScope.mockResolvedValue(null)
mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() })
mockQueryRows.mockResolvedValue(EMPTY_RESULT)
- mockGate.mockResolvedValue(null)
+ mockIsFeatureEnabled.mockResolvedValue(true)
+ mockGetWorkspaceOrganizationId.mockResolvedValue('org-1')
})
- it('returns 404 when the tables-v2-api flag is off', async () => {
- const { NextResponse } = await import('next/server')
- mockGate.mockResolvedValue(NextResponse.json({ error: 'Not found' }, { status: 404 }))
- const res = await callQuery({ workspaceId: 'workspace-1' })
- expect(res.status).toBe(404)
- expect(mockQueryRows).not.toHaveBeenCalled()
- })
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
- it('runs the flag gate only after the access check, so it cannot leak a cohort oracle', async () => {
- mockCheckAccess.mockResolvedValue({ ok: false, status: 403 })
const res = await callQuery({ workspaceId: 'workspace-1' })
- expect(res.status).toBe(403)
- expect(mockGate).not.toHaveBeenCalled()
- })
- it('translates a name-keyed predicate to storage ids', async () => {
- const res = await callQuery({
- workspaceId: 'workspace-1',
- predicate: {
- all: [
- { field: 'status', op: 'eq', value: 'active' },
- { field: 'wins', op: 'gte', value: 10 },
- ],
- },
- })
- expect(res.status).toBe(200)
- expect(mockQueryRows.mock.calls[0][1].predicate).toEqual({
- all: [
- { field: 'col_status', op: 'eq', value: 'active' },
- { field: 'col_wins', op: 'gte', value: 10 },
- ],
- })
+ expect(res.status).toBe(404)
+ expect((await res.json()).error.code).toBe('NOT_FOUND')
+ expect(mockQueryRows).not.toHaveBeenCalled()
})
it('applies the bounded default limit when omitted', async () => {
@@ -174,7 +167,7 @@ describe('POST /api/v2/tables/[tableId]/query', () => {
predicate: { all: [{ field: 'nope', op: 'eq', value: 1 }] },
})
expect(res.status).toBe(400)
- expect((await res.json()).error).toMatch(/Unknown filter column/)
+ expect((await res.json()).error.message).toMatch(/Unknown filter column/)
})
it('rejects a keyset cursor combined with a custom sort', async () => {
@@ -189,7 +182,7 @@ describe('POST /api/v2/tables/[tableId]/query', () => {
cursor,
})
expect(res.status).toBe(400)
- expect((await res.json()).code).toBe('CURSOR_SORT_CONFLICT')
+ expect((await res.json()).error.details.code).toBe('CURSOR_SORT_CONFLICT')
})
it('returns 400 INVALID_CURSOR for a malformed cursor', async () => {
@@ -198,23 +191,28 @@ describe('POST /api/v2/tables/[tableId]/query', () => {
cursor: Buffer.from('42').toString('base64url'),
})
expect(res.status).toBe(400)
- expect((await res.json()).code).toBe('INVALID_CURSOR')
+ expect((await res.json()).error.details.code).toBe('INVALID_CURSOR')
})
- it('surfaces a workspace-scope 403 from the middleware', async () => {
- const { NextResponse } = await import('next/server')
- mockCheckWorkspaceScope.mockResolvedValue(
- NextResponse.json({ error: 'not authorized' }, { status: 403 })
- )
+ it('surfaces a workspace-scope 403 in the v2 error envelope', async () => {
+ mockResolveWorkspaceScope.mockResolvedValue({
+ status: 403,
+ code: 'FORBIDDEN',
+ message: 'API key is not authorized for this workspace',
+ })
const res = await callQuery({ workspaceId: 'workspace-1' })
expect(res.status).toBe(403)
+ expect((await res.json()).error.code).toBe('FORBIDDEN')
expect(mockQueryRows).not.toHaveBeenCalled()
})
- it('rejects a workspace-id mismatch against the table', async () => {
+ it('masks a workspace-id mismatch against the table as 404', async () => {
const res = await callQuery({ workspaceId: 'other-ws' })
- expect(res.status).toBe(400)
- expect((await res.json()).error).toBe('Invalid workspace ID')
+ expect(res.status).toBe(404)
+ expect((await res.json()).error).toMatchObject({
+ code: 'NOT_FOUND',
+ message: 'Table not found',
+ })
})
it('returns name-keyed row data with no storage internals and a private cache header', async () => {
@@ -238,7 +236,9 @@ describe('POST /api/v2/tables/[tableId]/query', () => {
})
const res = await callQuery({ workspaceId: 'workspace-1' })
expect(res.headers.get('Cache-Control')).toBe('private, no-store')
- expect((await res.json()).data.rows[0]).toEqual({
+ const body = await res.json()
+ expect(body.nextCursor).toBeNull()
+ expect(body.data[0]).toEqual({
id: 'r1',
data: { status: 'active', wins: 12 },
createdAt: '2024-02-02T00:00:00.000Z',
@@ -247,7 +247,13 @@ describe('POST /api/v2/tables/[tableId]/query', () => {
})
it('returns the rate-limit response when the limiter denies the request', async () => {
- mockCheckRateLimit.mockResolvedValue({ allowed: false })
+ mockCheckRateLimit.mockResolvedValue({
+ allowed: false,
+ limit: 100,
+ remaining: 0,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+ retryAfterMs: 1000,
+ })
const res = await callQuery({ workspaceId: 'workspace-1' })
expect(res.status).toBe(429)
expect(mockCheckAccess).not.toHaveBeenCalled()
diff --git a/apps/sim/app/api/v2/tables/[tableId]/query/route.ts b/apps/sim/app/api/v2/tables/[tableId]/query/route.ts
index 4337c0b402c..495c8aeff48 100644
--- a/apps/sim/app/api/v2/tables/[tableId]/query/route.ts
+++ b/apps/sim/app/api/v2/tables/[tableId]/query/route.ts
@@ -1,8 +1,9 @@
import { createLogger } from '@sim/logger'
-import { type NextRequest, NextResponse } from 'next/server'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
import { TABLE_QUERY_MAX_BODY_BYTES } from '@/lib/api/contracts/tables'
import { V2_DEFAULT_ROW_LIMIT, v2QueryRowsContract } from '@/lib/api/contracts/v2/tables'
-import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server'
+import { isZodError, parseRequest } from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { Sort, TablePredicate, TableSchema } from '@/lib/table'
@@ -13,21 +14,23 @@ import { validatePredicate, validateSortSpec } from '@/lib/table/query-builder/v
import { assertCursorSortBinding, decodeCursor } from '@/lib/table/rows/cursor'
import { queryRows } from '@/lib/table/rows/service'
import { predicateToStorage } from '@/lib/table/select-values'
-import { accessError, checkAccess, tablesV2GateError } from '@/app/api/table/utils'
+import { checkAccess } from '@/app/api/table/utils'
+import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
import {
- checkRateLimit,
- checkWorkspaceScope,
- createRateLimitResponse,
-} from '@/app/api/v1/middleware'
+ v2CursorList,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+import { toApiRow } from '@/app/api/v2/tables/utils'
const logger = createLogger('V2TableQueryAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
-/** Filters may carry user data; keep query responses out of shared caches. */
-const PRIVATE_NO_STORE = { 'Cache-Control': 'private, no-store' } as const
-
interface QueryRouteParams {
params: Promise<{ tableId: string }>
}
@@ -41,34 +44,35 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Query
const requestId = generateRequestId()
try {
- const rateLimit = await checkRateLimit(request, 'v2-table-rows')
- if (!rateLimit.allowed) return createRateLimitResponse(rateLimit)
+ const rateLimit = await checkRateLimit(request, 'table-rows')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
const parsed = await parseRequest(v2QueryRowsContract, request, context, {
maxBodyBytes: TABLE_QUERY_MAX_BODY_BYTES,
+ validationErrorResponse: v2ValidationError,
})
if (!parsed.success) return parsed.response
const { tableId } = parsed.data.params
const { workspaceId, sort, cursor: cursorToken, limit } = parsed.data.body
- const scopeError = await checkWorkspaceScope(rateLimit, workspaceId)
- if (scopeError) return scopeError
+ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
const accessResult = await checkAccess(tableId, userId, 'read')
- if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
- const { table } = accessResult
+ // Mask not-authorized and not-found alike so cross-workspace existence never leaks.
+ if (!accessResult.ok) return v2Error('NOT_FOUND', 'Table not found')
+ const { table } = accessResult
if (workspaceId !== table.workspaceId) {
- return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
+ return v2Error('NOT_FOUND', 'Table not found')
}
- // After authz: the gate reads the workspace's org off the primary DB, and its
- // 404 would otherwise distinguish "not in the rollout cohort" from "no access".
- const gateError = await tablesV2GateError(userId, workspaceId)
- if (gateError) return gateError
-
const schema = table.schema as TableSchema
const cursor = cursorToken ? decodeCursor(cursorToken) : undefined
@@ -108,44 +112,29 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Query
limit: effectiveLimit,
after: cursor?.after,
offset: cursor?.offset,
- includeTotal: !cursorToken,
+ includeTotal: false,
withExecutions: false,
},
requestId
)
- return NextResponse.json(
- {
- success: true,
- data: {
- rows: result.rows.map((r) => ({
- id: r.id,
- data: toNamedRow(r.data),
- createdAt:
- r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt),
- updatedAt:
- r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt),
- })),
- rowCount: result.rowCount,
- totalCount: result.totalCount,
- limit: result.limit,
- nextCursor: result.nextCursor,
- },
- },
- { headers: PRIVATE_NO_STORE }
+ return v2CursorList(
+ result.rows.map((r) => toApiRow(r, toNamedRow)),
+ result.nextCursor,
+ { rateLimit }
)
} catch (error) {
- const validationResponse = validationErrorResponseFromError(error)
- if (validationResponse) return validationResponse
+ if (isZodError(error)) return v2ValidationError(error)
if (error instanceof TableQueryValidationError) {
- return NextResponse.json(
- { error: error.message, ...(error.code ? { code: error.code } : {}) },
- { status: 400, headers: PRIVATE_NO_STORE }
- )
+ return v2Error('BAD_REQUEST', error.message, {
+ details: error.code ? { code: error.code } : undefined,
+ })
}
- logger.error(`[${requestId}] Error querying rows (v2 public):`, error)
- return NextResponse.json({ error: 'Failed to query rows' }, { status: 500 })
+ logger.error(`[${requestId}] Error querying rows (v2 public)`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
}
})
diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts
new file mode 100644
index 00000000000..6b446248750
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts
@@ -0,0 +1,476 @@
+/**
+ * @vitest-environment node
+ *
+ * Public v2 table delete and update. Delete hands the actor to the service so
+ * the audit is emitted there — and only for a delete that actually archived a
+ * row. Update routes each field to its own orchestration call; lock flags are
+ * read-only on this surface and a request carrying them is refused outright.
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockResolveWorkspaceScope,
+ mockCheckAccess,
+ mockPerformDeleteTable,
+ mockPerformRenameTable,
+ mockPerformUpdateTableDescription,
+ mockPerformMoveTableToFolder,
+ mockPerformUpdateTableLocks,
+ mockRecordAudit,
+ mockGetTableById,
+ mockLoadActiveFolderPathIndex,
+ mockGateError,
+ mockSignalSchemaChanged,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceScope: vi.fn(),
+ mockCheckAccess: vi.fn(),
+ mockPerformDeleteTable: vi.fn(),
+ mockPerformRenameTable: vi.fn(),
+ mockPerformUpdateTableDescription: vi.fn(),
+ mockPerformMoveTableToFolder: vi.fn(),
+ mockPerformUpdateTableLocks: vi.fn(),
+ mockRecordAudit: vi.fn(),
+ mockGetTableById: vi.fn(),
+ mockLoadActiveFolderPathIndex: vi.fn(),
+ mockGateError: vi.fn(),
+ mockSignalSchemaChanged: vi.fn(),
+}))
+
+vi.mock('@sim/audit', () => ({
+ AuditAction: { TABLE_DELETED: 'table.deleted', TABLE_UPDATED: 'table.updated' },
+ AuditResourceType: { TABLE: 'table' },
+ recordAudit: mockRecordAudit,
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceScope: mockResolveWorkspaceScope,
+}))
+
+vi.mock('@/app/api/table/utils', () => ({
+ checkAccess: mockCheckAccess,
+ normalizeColumn: (col: Record) => col,
+ rootErrorMessage: (error: unknown) => String(error),
+ rowWriteErrorResponse: () => null,
+}))
+
+vi.mock('@/lib/table', () => ({
+ updateTable: vi.fn(),
+ getTableById: mockGetTableById,
+ updateRow: vi.fn(),
+ rowDataNameToId: vi.fn(),
+ buildIdByName: vi.fn(),
+}))
+
+vi.mock('@/lib/table/events', () => ({
+ signalTableSchemaChanged: mockSignalSchemaChanged,
+}))
+vi.mock('@/lib/folders/queries', () => ({
+ loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex,
+}))
+vi.mock('@/lib/workspaces/permissions/utils', () => ({
+ getWorkspaceWithOwner: vi.fn().mockResolvedValue({ organizationId: 'org-1' }),
+}))
+
+vi.mock('@/lib/table/orchestration', () => ({
+ performDeleteTable: mockPerformDeleteTable,
+ performRenameTable: mockPerformRenameTable,
+ performUpdateTableDescription: mockPerformUpdateTableDescription,
+ performMoveTableToFolder: mockPerformMoveTableToFolder,
+ performUpdateTableLocks: mockPerformUpdateTableLocks,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError }))
+
+import { DELETE, PATCH } from '@/app/api/v2/tables/[tableId]/route'
+
+const UNLOCKED = {
+ schemaLocked: false,
+ insertLocked: false,
+ updateLocked: false,
+ deleteLocked: false,
+}
+const TABLE = {
+ id: 'table-1',
+ name: 'Tasks',
+ workspaceId: 'ws-1',
+ schema: { columns: [] },
+ locks: UNLOCKED,
+}
+const UPDATED_TABLE = {
+ ...TABLE,
+ name: 'Renamed',
+ description: null,
+ rowCount: 0,
+ maxRows: 1000,
+ folderId: null,
+ createdAt: new Date('2026-01-01T00:00:00Z'),
+ updatedAt: new Date('2026-01-02T00:00:00Z'),
+}
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ workspaceId: 'ws-1',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2026-01-01T01:00:00Z'),
+}
+
+function callDelete() {
+ const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1?workspaceId=ws-1', {
+ method: 'DELETE',
+ })
+ return DELETE(req, { params: Promise.resolve({ tableId: 'table-1' }) })
+}
+
+function callPatch(body: unknown) {
+ const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ return PATCH(req, { params: Promise.resolve({ tableId: 'table-1' }) })
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceScope.mockResolvedValue(null)
+ mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE })
+ mockGetTableById.mockResolvedValue(UPDATED_TABLE)
+ mockLoadActiveFolderPathIndex.mockResolvedValue({
+ rowById: new Map([['folder-1', { id: 'folder-1', name: 'Reports', parentId: null }]]),
+ pathById: new Map([['folder-1', '/Reports']]),
+ idByPath: new Map([['/Reports', 'folder-1']]),
+ })
+ mockGateError.mockResolvedValue(null)
+})
+
+describe('DELETE /api/v2/tables/[tableId]', () => {
+ it('delegates to the orchestration function with the resolved table and actor', async () => {
+ mockPerformDeleteTable.mockResolvedValue({ success: true })
+
+ const res = await callDelete()
+
+ expect(res.status).toBe(200)
+ expect(mockPerformDeleteTable).toHaveBeenCalledWith(
+ expect.objectContaining({ table: TABLE, userId: 'user-1' })
+ )
+ expect((await res.json()).data).toEqual({ id: 'table-1', deleted: true })
+ // The route no longer audits: doing so out here fired TABLE_DELETED even
+ // when the delete was a no-op on an already-archived table.
+ expect(mockRecordAudit).not.toHaveBeenCalled()
+ })
+
+ it('returns 423 LOCKED for a delete-locked table instead of a 500', async () => {
+ mockPerformDeleteTable.mockResolvedValue({
+ success: false,
+ errorCode: 'locked',
+ error: 'Table is locked',
+ })
+
+ const res = await callDelete()
+
+ expect(res.status).toBe(423)
+ expect((await res.json()).error.code).toBe('LOCKED')
+ })
+})
+
+describe('PATCH /api/v2/tables/[tableId]', () => {
+ it('renames through the orchestration function and returns the re-read table', async () => {
+ mockPerformRenameTable.mockResolvedValue({ success: true })
+
+ const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' })
+
+ expect(res.status).toBe(200)
+ expect((await res.json()).data).toEqual({
+ table: {
+ id: 'table-1',
+ name: 'Renamed',
+ description: null,
+ schema: { columns: [] },
+ rowCount: 0,
+ maxRows: 1000,
+ folderPath: '/',
+ locks: UNLOCKED,
+ job: null,
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ },
+ })
+ expect(mockPerformRenameTable).toHaveBeenCalledWith(
+ expect.objectContaining({ table: TABLE, newName: 'Renamed', userId: 'user-1' })
+ )
+ expect(mockPerformMoveTableToFolder).not.toHaveBeenCalled()
+ expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled()
+ })
+
+ it('updates and clears the table description through orchestration', async () => {
+ mockPerformUpdateTableDescription.mockResolvedValue({ success: true })
+
+ const updateResponse = await callPatch({ workspaceId: 'ws-1', description: 'Finance data' })
+
+ expect(updateResponse.status).toBe(200)
+ expect(mockPerformUpdateTableDescription).toHaveBeenCalledWith(
+ expect.objectContaining({
+ table: TABLE,
+ description: 'Finance data',
+ userId: 'user-1',
+ })
+ )
+
+ const clearResponse = await callPatch({ workspaceId: 'ws-1', description: null })
+ expect(clearResponse.status).toBe(200)
+ expect(mockPerformUpdateTableDescription).toHaveBeenLastCalledWith(
+ expect.objectContaining({ description: null })
+ )
+ })
+
+ it('surfaces a running import so an async job is observable, not just startable', async () => {
+ // `POST /import-async` and `POST /job/cancel` let a caller start and stop an
+ // import; without this the table never reports that it is running, so there
+ // is nothing to poll between the two.
+ mockGetTableById.mockResolvedValue({
+ ...UPDATED_TABLE,
+ jobStatus: 'running',
+ jobId: 'job-1',
+ jobType: 'import',
+ jobRowsProcessed: 250,
+ jobError: null,
+ })
+ mockPerformRenameTable.mockResolvedValue({ success: true })
+
+ const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' })
+
+ expect((await res.json()).data.table.job).toEqual({
+ id: 'job-1',
+ type: 'import',
+ status: 'running',
+ rowsProcessed: 250,
+ error: null,
+ })
+ })
+
+ it('moves the table only after confirming the folder belongs to the workspace', async () => {
+ mockPerformMoveTableToFolder.mockResolvedValue({ success: true })
+
+ const res = await callPatch({ workspaceId: 'ws-1', folderPath: '/Reports' })
+
+ expect(res.status).toBe(200)
+ expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith('ws-1', 'table', expect.any(Object))
+ expect(mockPerformMoveTableToFolder).toHaveBeenCalledWith(
+ expect.objectContaining({ table: TABLE, folderId: 'folder-1', userId: 'user-1' })
+ )
+ })
+
+ it('404s a folder from outside the workspace without attempting the move', async () => {
+ const res = await callPatch({ workspaceId: 'ws-1', folderPath: '/Elsewhere' })
+
+ expect(res.status).toBe(404)
+ expect(mockPerformMoveTableToFolder).not.toHaveBeenCalled()
+ })
+
+ it('rejects a bad folder without applying the rename that came with it', async () => {
+ // The three operations are separate transactions, so validation has to run
+ // before the first write — otherwise a rejected PATCH still renames.
+ const res = await callPatch({
+ workspaceId: 'ws-1',
+ name: 'Renamed',
+ folderPath: '/Elsewhere',
+ })
+
+ expect(res.status).toBe(404)
+ expect(mockPerformRenameTable).not.toHaveBeenCalled()
+ expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled()
+ expect(mockSignalSchemaChanged).not.toHaveBeenCalled()
+ })
+
+ it('reports which operations landed when a later one fails', async () => {
+ // The three writes commit independently, so rather than pretending
+ // atomicity the error states what is already live — a caller can reconcile
+ // instead of re-reading and diffing.
+ mockPerformRenameTable.mockResolvedValue({ success: true })
+ mockPerformMoveTableToFolder.mockResolvedValue({
+ success: false,
+ errorCode: 'not_found',
+ error: 'gone',
+ })
+
+ const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderPath: '/Reports' })
+
+ expect(res.status).toBe(404)
+ expect((await res.json()).error.details).toEqual({ applied: ['name'] })
+ })
+
+ it('omits the applied list when the very first operation fails', async () => {
+ // `details.applied` present must always mean "these changes are live".
+ mockPerformRenameTable.mockResolvedValue({
+ success: false,
+ errorCode: 'conflict',
+ error: 'taken',
+ })
+
+ const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderPath: '/Reports' })
+
+ expect(res.status).toBe(409)
+ expect((await res.json()).error.details).toBeUndefined()
+ expect(mockPerformMoveTableToFolder).not.toHaveBeenCalled()
+ })
+
+ it('still signals collaborators when a later operation fails after an earlier one landed', async () => {
+ // A mid-write fault can't be rolled back across three transactions, so the
+ // clients must at least be told to refetch what did apply.
+ mockPerformRenameTable.mockResolvedValue({ success: true })
+ mockPerformMoveTableToFolder.mockResolvedValue({
+ success: false,
+ errorCode: 'not_found',
+ error: 'gone',
+ })
+
+ const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderPath: '/Reports' })
+
+ expect(res.status).toBe(404)
+ expect(mockPerformRenameTable).toHaveBeenCalled()
+ expect(mockSignalSchemaChanged).toHaveBeenCalledWith('table-1')
+ })
+
+ /**
+ * Locks are read-only on the public API. A `write`-level API key can already
+ * mutate the table, so letting it clear a lock would let it undo the guard
+ * placed there to stop it. The strict body rejects the field outright rather
+ * than dropping it silently, which would report success for a change that
+ * never happened.
+ */
+ it('rejects a lock change instead of applying or silently ignoring it', async () => {
+ const res = await callPatch({ workspaceId: 'ws-1', locks: { deleteLocked: true } })
+
+ expect(res.status).toBe(400)
+ const body = await res.json()
+ expect(body.error.code).toBe('BAD_REQUEST')
+ expect(JSON.stringify(body.error)).toContain('locks')
+ expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled()
+ })
+
+ it('rejects a lock change even when paired with an otherwise valid rename', async () => {
+ const res = await callPatch({
+ workspaceId: 'ws-1',
+ name: 'Renamed',
+ locks: { deleteLocked: false },
+ })
+
+ expect(res.status).toBe(400)
+ // The whole request is refused — the rename must not land either.
+ expect(mockPerformRenameTable).not.toHaveBeenCalled()
+ })
+
+ /**
+ * The re-read runs after the writes have committed, so a failure there must
+ * still name what landed. Reporting a bare 500 tells the caller nothing took
+ * effect and it retries into a duplicate-name conflict.
+ */
+ it('reports the applied operations when the final re-read throws', async () => {
+ mockPerformRenameTable.mockResolvedValue({ success: true })
+ mockGetTableById.mockRejectedValue(new Error('connection reset'))
+
+ const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' })
+
+ expect(res.status).toBe(500)
+ expect((await res.json()).error.details).toEqual({ applied: ['name'] })
+ })
+
+ it('reports the applied operations when the re-read finds the table archived', async () => {
+ mockPerformRenameTable.mockResolvedValue({ success: true })
+ mockGetTableById.mockResolvedValue(null)
+
+ const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' })
+
+ expect(res.status).toBe(404)
+ expect((await res.json()).error.details).toEqual({ applied: ['name'] })
+ })
+
+ it('omits applied details when the failure happened before any write', async () => {
+ mockGetTableById.mockRejectedValue(new Error('connection reset'))
+
+ mockLoadActiveFolderPathIndex.mockRejectedValue(new Error('connection reset'))
+
+ const res = await callPatch({ workspaceId: 'ws-1', folderPath: '/Nope' })
+
+ // Absence is meaningful: nothing is live, so a retry is safe.
+ expect((await res.json()).error.details).toBeUndefined()
+ })
+
+ it('still reports the stored lock flags on the table it returns', async () => {
+ // The response is a re-read, so the locked state has to come from there.
+ mockGetTableById.mockResolvedValue({
+ ...UPDATED_TABLE,
+ locks: { ...UNLOCKED, deleteLocked: true },
+ })
+
+ const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' })
+
+ expect(res.status).toBe(200)
+ expect((await res.json()).data.table.locks).toMatchObject({ deleteLocked: true })
+ })
+
+ it('maps a duplicate-name rename to 409 CONFLICT', async () => {
+ mockPerformRenameTable.mockResolvedValue({
+ success: false,
+ errorCode: 'conflict',
+ error: 'A table named "Renamed" already exists',
+ })
+
+ const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' })
+
+ expect(res.status).toBe(409)
+ expect((await res.json()).error.code).toBe('CONFLICT')
+ })
+
+ it('rejects a body with nothing to change', async () => {
+ const res = await callPatch({ workspaceId: 'ws-1' })
+
+ expect(res.status).toBe(400)
+ expect(mockPerformRenameTable).not.toHaveBeenCalled()
+ })
+
+ it('404s a table in another workspace without writing', async () => {
+ mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, workspaceId: 'ws-other' } })
+
+ const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' })
+
+ expect(res.status).toBe(404)
+ expect(mockPerformRenameTable).not.toHaveBeenCalled()
+ })
+
+ it('404s with the gate off, before any work', async () => {
+ mockGateError.mockResolvedValue(
+ new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), {
+ status: 404,
+ })
+ )
+
+ const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' })
+
+ expect(res.status).toBe(404)
+ expect(mockCheckAccess).not.toHaveBeenCalled()
+ expect(mockPerformRenameTable).not.toHaveBeenCalled()
+ })
+
+ it('429s a throttled caller', async () => {
+ mockCheckRateLimit.mockResolvedValue({
+ ...RATE_LIMIT_OK,
+ allowed: false,
+ remaining: 0,
+ retryAfterMs: 1000,
+ })
+
+ const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' })
+
+ expect(res.status).toBe(429)
+ expect(mockPerformRenameTable).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts
new file mode 100644
index 00000000000..4df088ba0ce
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts
@@ -0,0 +1,297 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import {
+ v2DeleteTableContract,
+ v2GetTableContract,
+ v2UpdateTableContract,
+} from '@/lib/api/contracts/v2/tables'
+import { parseRequest } from '@/lib/api/server'
+import { asOrchestrationError } from '@/lib/core/orchestration/types'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { loadActiveFolderPathIndex } from '@/lib/folders/queries'
+import { getTableById } from '@/lib/table'
+import { signalTableSchemaChanged } from '@/lib/table/events'
+import {
+ performDeleteTable,
+ performMoveTableToFolder,
+ performRenameTable,
+ performUpdateTableDescription,
+} from '@/lib/table/orchestration'
+import { checkAccess } from '@/app/api/table/utils'
+import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware'
+import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+import type { OrchestrationOutcome } from '@/app/api/v2/tables/utils'
+import {
+ toApiTable,
+ v2TableAccessError,
+ v2TableLockError,
+ v2TableOrchestrationError,
+} from '@/app/api/v2/tables/utils'
+
+const logger = createLogger('V2TableDetailAPI')
+
+/**
+ * `details` payload naming the operations of a composite write that committed,
+ * or `undefined` when none did — so `details.applied` being present always
+ * means "these changes are live despite the error".
+ */
+function appliedDetails(
+ applied: readonly ('name' | 'description' | 'folderPath')[]
+): { applied: readonly string[] } | undefined {
+ return applied.length > 0 ? { applied } : undefined
+}
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+interface TableRouteParams {
+ params: Promise<{ tableId: string }>
+}
+
+/** GET /api/v2/tables/[tableId] — Get table details. */
+export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2GetTableContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+
+ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const result = await checkAccess(tableId, userId, 'read')
+ // Mask not-authorized and not-found alike so cross-workspace existence never leaks.
+ if (!result.ok) return v2Error('NOT_FOUND', 'Table not found')
+
+ if (result.table.workspaceId !== workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'table')
+ return v2Data(
+ { table: toApiTable(result.table, folderPathForId(folderIndex, result.table.folderId)) },
+ { rateLimit }
+ )
+ } catch (error) {
+ logger.error(`[${requestId}] Error getting table`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/**
+ * PATCH /api/v2/tables/[tableId] — Rename and/or move a table.
+ *
+ * Each field routes to its own orchestration call so the audit records the
+ * operation the caller actually performed.
+ *
+ * Lock flags are **not** settable here. They are readable on the table resource
+ * and enforced on every write, but an API key that can mutate a table must not
+ * also be able to clear the lock placed there to stop it; changing a lock stays
+ * a first-party admin action. The contract body is `.strict()`, so a request
+ * carrying `locks` is rejected rather than silently ignored.
+ */
+export const PATCH = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
+ const requestId = generateRequestId()
+
+ /**
+ * Hoisted above the `try` so every exit path can report it. Once a write has
+ * committed, the response must say so even when the failure came *after* the
+ * writes — a throw in the final re-read, or the re-read finding the table
+ * archived. Reporting a bare 500 there tells the caller nothing landed, and
+ * it retries into a duplicate-name conflict or a repeated move.
+ */
+ const applied: ('name' | 'description' | 'folderPath')[] = []
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2UpdateTableContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId } = parsed.data.params
+ const validated = parsed.data.body
+
+ const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const result = await checkAccess(tableId, userId, 'write')
+ if (!result.ok) return v2TableAccessError(result)
+
+ const { table } = result
+ if (table.workspaceId !== validated.workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ const resolution =
+ validated.folderPath === undefined
+ ? undefined
+ : await resolveFolderPathIdentity({
+ workspaceId: table.workspaceId,
+ resourceType: 'table',
+ path: validated.folderPath,
+ })
+ if (resolution && !resolution.found) {
+ return v2Error('NOT_FOUND', 'Folder not found in this workspace')
+ }
+
+ let failure: { outcome: OrchestrationOutcome; fallback: string } | null = null
+
+ if (validated.name !== undefined) {
+ const outcome = await performRenameTable({
+ table,
+ newName: validated.name,
+ userId,
+ requestId,
+ request,
+ })
+ if (outcome.success) applied.push('name')
+ else failure = { outcome, fallback: 'Failed to rename table' }
+ }
+
+ if (!failure && validated.description !== undefined) {
+ const outcome = await performUpdateTableDescription({
+ table,
+ description: validated.description,
+ userId,
+ requestId,
+ request,
+ })
+ if (outcome.success) applied.push('description')
+ else failure = { outcome, fallback: 'Failed to update table description' }
+ }
+
+ if (!failure && validated.folderPath !== undefined) {
+ const outcome = await performMoveTableToFolder({
+ table,
+ folderId: resolution?.folderId ?? null,
+ userId,
+ requestId,
+ request,
+ })
+ if (outcome.success) {
+ applied.push('folderPath')
+ } else {
+ failure = {
+ outcome:
+ outcome.errorCode === 'not_found' ? { ...outcome, error: 'Table not found' } : outcome,
+ fallback: 'Failed to move table',
+ }
+ }
+ }
+
+ if (applied.length > 0) signalTableSchemaChanged(tableId)
+ if (failure) {
+ return v2TableOrchestrationError(failure.outcome, failure.fallback, appliedDetails(applied))
+ }
+
+ const updated = await getTableById(tableId)
+ if (!updated) {
+ return v2Error('NOT_FOUND', 'Table not found', { details: appliedDetails(applied) })
+ }
+
+ const folderIndex = await loadActiveFolderPathIndex(table.workspaceId, 'table')
+ return v2Data(
+ { table: toApiTable(updated, folderPathForId(folderIndex, updated.folderId)) },
+ { rateLimit }
+ )
+ } catch (error) {
+ const details = appliedDetails(applied)
+
+ const lockError = v2TableLockError(error, details)
+ if (lockError) return lockError
+
+ const classified = asOrchestrationError(error)
+ if (classified) {
+ return v2TableOrchestrationError(
+ { errorCode: classified.code, error: classified.message },
+ 'Failed to update table',
+ details
+ )
+ }
+
+ logger.error(`[${requestId}] Error updating table`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ applied,
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error', { details })
+ }
+})
+
+export const DELETE = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2DeleteTableContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+
+ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const result = await checkAccess(tableId, userId, 'write')
+ if (!result.ok) return v2TableAccessError(result)
+
+ if (result.table.workspaceId !== workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ const outcome = await performDeleteTable({ table: result.table, userId, requestId, request })
+ if (!outcome.success) {
+ return v2TableOrchestrationError(outcome, 'Failed to delete table')
+ }
+
+ return v2Data({ id: tableId, deleted: true }, { rateLimit })
+ } catch (error) {
+ const lockError = v2TableLockError(error)
+ if (lockError) return lockError
+ logger.error(`[${requestId}] Error deleting table`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts
new file mode 100644
index 00000000000..cc1566ce848
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts
@@ -0,0 +1,160 @@
+/**
+ * @vitest-environment node
+ *
+ * Public v2 per-row enrichment run — the single-cell case of the column run.
+ * Naming a specific cell is an explicit re-run, so it dispatches in `all` mode
+ * and recomputes an already-populated cell.
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockResolveWorkspaceScope,
+ mockCheckAccess,
+ mockRunWorkflowColumn,
+ mockSignalRowsChanged,
+ mockGateError,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceScope: vi.fn(),
+ mockCheckAccess: vi.fn(),
+ mockRunWorkflowColumn: vi.fn(),
+ mockSignalRowsChanged: vi.fn(),
+ mockGateError: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceScope: mockResolveWorkspaceScope,
+}))
+
+vi.mock('@/app/api/table/utils', () => ({
+ checkAccess: mockCheckAccess,
+ normalizeColumn: (col: Record) => col,
+ rootErrorMessage: (error: unknown) => String(error),
+ rowWriteErrorResponse: () => null,
+}))
+
+vi.mock('@/lib/table/workflow-columns', () => ({ runWorkflowColumn: mockRunWorkflowColumn }))
+vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mockSignalRowsChanged }))
+vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError }))
+
+import { POST } from '@/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route'
+
+const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] } }
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ workspaceId: 'ws-1',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2026-01-01T01:00:00Z'),
+}
+
+function callPost(body: unknown) {
+ const req = new NextRequest(
+ 'http://localhost:3000/api/v2/tables/table-1/rows/row-1/enrichment/group-1',
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ }
+ )
+ return POST(req, {
+ params: Promise.resolve({ tableId: 'table-1', rowId: 'row-1', groupId: 'group-1' }),
+ })
+}
+
+describe('POST /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceScope.mockResolvedValue(null)
+ mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE })
+ mockRunWorkflowColumn.mockResolvedValue({ dispatchId: 'dispatch-1' })
+ mockGateError.mockResolvedValue(null)
+ })
+
+ it('scopes the dispatch to the one row and group in the path', async () => {
+ const res = await callPost({ workspaceId: 'ws-1' })
+
+ expect(res.status).toBe(200)
+ expect((await res.json()).data).toEqual({ dispatchId: 'dispatch-1' })
+ expect(mockRunWorkflowColumn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ tableId: 'table-1',
+ workspaceId: 'ws-1',
+ groupIds: ['group-1'],
+ rowIds: ['row-1'],
+ mode: 'all',
+ triggeredByUserId: 'user-1',
+ })
+ )
+ expect(mockSignalRowsChanged).toHaveBeenCalledWith('table-1')
+ })
+
+ it('reports a null dispatch id verbatim rather than inventing one', async () => {
+ mockRunWorkflowColumn.mockResolvedValue({ dispatchId: null })
+
+ const res = await callPost({ workspaceId: 'ws-1' })
+
+ expect(res.status).toBe(200)
+ expect((await res.json()).data).toEqual({ dispatchId: null })
+ })
+
+ it('404s a table in another workspace without dispatching', async () => {
+ mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, workspaceId: 'ws-other' } })
+
+ const res = await callPost({ workspaceId: 'ws-1' })
+
+ expect(res.status).toBe(404)
+ expect(mockRunWorkflowColumn).not.toHaveBeenCalled()
+ })
+
+ it('400s a body with no workspace', async () => {
+ const res = await callPost({})
+
+ expect(res.status).toBe(400)
+ expect(mockRunWorkflowColumn).not.toHaveBeenCalled()
+ })
+
+ it('403s a read-only member', async () => {
+ mockCheckAccess.mockResolvedValue({ ok: false, status: 403 })
+
+ const res = await callPost({ workspaceId: 'ws-1' })
+
+ expect(res.status).toBe(403)
+ expect(mockRunWorkflowColumn).not.toHaveBeenCalled()
+ })
+
+ it('404s with the gate off, before any work', async () => {
+ mockGateError.mockResolvedValue(
+ new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), {
+ status: 404,
+ })
+ )
+
+ const res = await callPost({ workspaceId: 'ws-1' })
+
+ expect(res.status).toBe(404)
+ expect(mockCheckAccess).not.toHaveBeenCalled()
+ expect(mockRunWorkflowColumn).not.toHaveBeenCalled()
+ })
+
+ it('429s a throttled caller', async () => {
+ mockCheckRateLimit.mockResolvedValue({
+ ...RATE_LIMIT_OK,
+ allowed: false,
+ remaining: 0,
+ retryAfterMs: 1000,
+ })
+
+ const res = await callPost({ workspaceId: 'ws-1' })
+
+ expect(res.status).toBe(429)
+ expect(mockRunWorkflowColumn).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts
new file mode 100644
index 00000000000..9f3e7a27b69
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts
@@ -0,0 +1,96 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { v2RunRowEnrichmentContract } from '@/lib/api/contracts/v2/tables'
+import { parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { signalTableRowsChanged } from '@/lib/table/events'
+import { runWorkflowColumn } from '@/lib/table/workflow-columns'
+import { checkAccess } from '@/app/api/table/utils'
+import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CaughtOrchestrationError,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+import { v2TableAccessError, v2TableLockError } from '@/app/api/v2/tables/utils'
+
+const logger = createLogger('V2TableRowEnrichmentAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+interface RowEnrichmentRouteParams {
+ params: Promise<{ tableId: string; rowId: string; groupId: string }>
+}
+
+/**
+ * POST /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]
+ *
+ * The single-cell case of `POST /columns/run`: runs one group for one row.
+ * `mode: 'all'` because naming a specific cell is an explicit re-run request —
+ * an already-populated cell must recompute rather than be skipped.
+ */
+export const POST = withRouteHandler(
+ async (request: NextRequest, context: RowEnrichmentRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-enrichment')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2RunRowEnrichmentContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId, rowId, groupId } = parsed.data.params
+ const { workspaceId } = parsed.data.body
+
+ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const access = await checkAccess(tableId, userId, 'write')
+ if (!access.ok) return v2TableAccessError(access)
+
+ if (access.table.workspaceId !== workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ const { dispatchId } = await runWorkflowColumn({
+ tableId,
+ workspaceId,
+ groupIds: [groupId],
+ rowIds: [rowId],
+ mode: 'all',
+ requestId,
+ triggeredByUserId: userId,
+ })
+
+ signalTableRowsChanged(tableId)
+
+ return v2Data({ dispatchId }, { rateLimit })
+ } catch (error) {
+ const lockError = v2TableLockError(error)
+ if (lockError) return lockError
+
+ const classified = v2CaughtOrchestrationError(error)
+ if (classified) return classified
+
+ logger.error(`[${requestId}] Error running row enrichment`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts
new file mode 100644
index 00000000000..626dd3ba567
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts
@@ -0,0 +1,122 @@
+/**
+ * @vitest-environment node
+ *
+ * Public v2 single-row delete: goes through the row service so the delete lock
+ * and row-count bookkeeping are enforced, and renders lock/not-found in the v2
+ * error envelope.
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockCheckRateLimit, mockResolveWorkspaceScope, mockCheckAccess, mockPerformDeleteRow } =
+ vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceScope: vi.fn(),
+ mockCheckAccess: vi.fn(),
+ mockPerformDeleteRow: vi.fn(),
+ }))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceScope: mockResolveWorkspaceScope,
+}))
+
+vi.mock('@/app/api/table/utils', () => ({
+ checkAccess: mockCheckAccess,
+ normalizeColumn: (col: Record) => col,
+ rootErrorMessage: (error: unknown) => String(error),
+ rowWriteErrorResponse: () => null,
+}))
+
+vi.mock('@/lib/table', () => ({
+ updateTable: vi.fn(),
+ getTableById: vi.fn(),
+ updateRow: vi.fn(),
+ rowDataNameToId: vi.fn(),
+ buildIdByName: vi.fn(),
+}))
+
+vi.mock('@/lib/table/orchestration', () => ({ performDeleteTableRow: mockPerformDeleteRow }))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+import { DELETE } from '@/app/api/v2/tables/[tableId]/rows/[rowId]/route'
+
+const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] } }
+
+function callDelete() {
+ const req = new NextRequest(
+ 'http://localhost:3000/api/v2/tables/table-1/rows/row-1?workspaceId=ws-1',
+ { method: 'DELETE' }
+ )
+ return DELETE(req, { params: Promise.resolve({ tableId: 'table-1', rowId: 'row-1' }) })
+}
+
+describe('DELETE /api/v2/tables/[tableId]/rows/[rowId]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue({
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ workspaceId: 'ws-1',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2026-01-01T01:00:00Z'),
+ })
+ mockResolveWorkspaceScope.mockResolvedValue(null)
+ mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE })
+ })
+
+ it('delegates to the orchestration function rather than deleting inline', async () => {
+ mockPerformDeleteRow.mockResolvedValue({ success: true })
+
+ const res = await callDelete()
+
+ expect(res.status).toBe(200)
+ expect((await res.json()).data).toEqual({ deletedCount: 1, deletedRowIds: ['row-1'] })
+ // The orchestration function routes through the row service, which applies
+ // the delete lock and the row-count decrement; the raw delete this replaced
+ // skipped both.
+ expect(mockPerformDeleteRow).toHaveBeenCalledWith(
+ expect.objectContaining({ table: TABLE, rowId: 'row-1' })
+ )
+ })
+
+ it.each([
+ ['locked', 423, 'LOCKED'],
+ ['not_found', 404, 'NOT_FOUND'],
+ ])('maps a %s failure to %i', async (errorCode, status, code) => {
+ mockPerformDeleteRow.mockResolvedValue({ success: false, errorCode, error: 'nope' })
+
+ const res = await callDelete()
+
+ expect(res.status).toBe(status)
+ expect((await res.json()).error.code).toBe(code)
+ })
+
+ it('names the lock on a 423 that arrived as a classified outcome, not a throw', async () => {
+ mockPerformDeleteRow.mockResolvedValue({
+ success: false,
+ errorCode: 'locked',
+ error: 'Row deletes are locked for this table',
+ lock: 'delete',
+ })
+
+ const res = await callDelete()
+
+ expect(res.status).toBe(423)
+ expect((await res.json()).error.details).toEqual({ lock: 'delete' })
+ })
+
+ it('omits details entirely when the lock kind is unknown', async () => {
+ // A caller branching on `details.lock` should see absence, not a null.
+ mockPerformDeleteRow.mockResolvedValue({ success: false, errorCode: 'locked', error: 'nope' })
+
+ const res = await callDelete()
+
+ expect((await res.json()).error.details).toBeUndefined()
+ })
+})
diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts
new file mode 100644
index 00000000000..026348e69f9
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts
@@ -0,0 +1,231 @@
+import { db } from '@sim/db'
+import { userTableRows } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { and, eq } from 'drizzle-orm'
+import type { NextRequest } from 'next/server'
+import {
+ v2DeleteTableRowContract,
+ v2GetTableRowContract,
+ v2UpdateTableRowContract,
+} from '@/lib/api/contracts/v2/tables'
+import { isZodError, parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import type { RowData, TableSchema } from '@/lib/table'
+import { buildIdByName, rowDataNameToId, updateRow } from '@/lib/table'
+import { namedRowMapper } from '@/lib/table/cell-format'
+import { performDeleteTableRow } from '@/lib/table/orchestration'
+import { checkAccess } from '@/app/api/table/utils'
+import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CaughtOrchestrationError,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+import {
+ toApiRow,
+ v2TableAccessError,
+ v2TableLockError,
+ v2TableOrchestrationError,
+} from '@/app/api/v2/tables/utils'
+
+const logger = createLogger('V2TableRowAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+interface RowRouteParams {
+ params: Promise<{ tableId: string; rowId: string }>
+}
+
+/** GET /api/v2/tables/[tableId]/rows/[rowId] — Get a single row. */
+export const GET = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-row-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2GetTableRowContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId, rowId } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+
+ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const result = await checkAccess(tableId, userId, 'read')
+ // Mask not-authorized and not-found alike so cross-workspace existence never leaks.
+ if (!result.ok) return v2Error('NOT_FOUND', 'Table not found')
+
+ if (result.table.workspaceId !== workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ const [row] = await db
+ .select({
+ id: userTableRows.id,
+ data: userTableRows.data,
+ createdAt: userTableRows.createdAt,
+ updatedAt: userTableRows.updatedAt,
+ })
+ .from(userTableRows)
+ .where(
+ and(
+ eq(userTableRows.id, rowId),
+ eq(userTableRows.tableId, tableId),
+ eq(userTableRows.workspaceId, workspaceId)
+ )
+ )
+ .limit(1)
+
+ if (!row) return v2Error('NOT_FOUND', 'Row not found')
+
+ const toNamedRow = namedRowMapper((result.table.schema as TableSchema).columns)
+ return v2Data(
+ {
+ row: toApiRow(
+ {
+ id: row.id,
+ data: row.data as RowData,
+ createdAt: row.createdAt,
+ updatedAt: row.updatedAt,
+ },
+ toNamedRow
+ ),
+ },
+ { rateLimit }
+ )
+ } catch (error) {
+ logger.error(`[${requestId}] Error getting row`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/** PATCH /api/v2/tables/[tableId]/rows/[rowId] — Partial update a single row. */
+export const PATCH = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-row-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2UpdateTableRowContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId, rowId } = parsed.data.params
+ const validated = parsed.data.body
+
+ const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const result = await checkAccess(tableId, userId, 'write')
+ if (!result.ok) return v2TableAccessError(result)
+
+ const { table } = result
+ if (table.workspaceId !== validated.workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ const idByName = buildIdByName(table.schema as TableSchema)
+ const toNamedRow = namedRowMapper((table.schema as TableSchema).columns)
+ const updatedRow = await updateRow(
+ {
+ tableId,
+ rowId,
+ data: rowDataNameToId(validated.data as RowData, idByName),
+ workspaceId: validated.workspaceId,
+ actorUserId: userId,
+ },
+ table,
+ requestId
+ )
+ // No `cancellationGuard` is passed, so `updateRow` can't return null here.
+ // Defensive narrowing for TypeScript.
+ if (!updatedRow) return v2Error('NOT_FOUND', 'Row not found')
+
+ return v2Data({ row: toApiRow(updatedRow, toNamedRow) }, { rateLimit })
+ } catch (error) {
+ if (isZodError(error)) return v2ValidationError(error)
+
+ const classified = v2CaughtOrchestrationError(error)
+ if (classified) return classified
+
+ logger.error(`[${requestId}] Error updating row`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/** DELETE /api/v2/tables/[tableId]/rows/[rowId] — Delete a single row. */
+export const DELETE = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-row-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2DeleteTableRowContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId, rowId } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+
+ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const result = await checkAccess(tableId, userId, 'write')
+ if (!result.ok) return v2TableAccessError(result)
+
+ if (result.table.workspaceId !== workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ const outcome = await performDeleteTableRow({ table: result.table, rowId, requestId })
+ if (!outcome.success) {
+ return v2TableOrchestrationError(outcome, 'Failed to delete row')
+ }
+
+ // v2 mirrors the bulk delete shape: always returns `deletedRowIds`.
+ return v2Data({ deletedCount: 1, deletedRowIds: [rowId] }, { rateLimit })
+ } catch (error) {
+ const lockError = v2TableLockError(error)
+ if (lockError) return lockError
+ const classified = v2CaughtOrchestrationError(error)
+ if (classified) return classified
+ logger.error(`[${requestId}] Error deleting row`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts
new file mode 100644
index 00000000000..19f38dfb59f
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts
@@ -0,0 +1,207 @@
+/**
+ * @vitest-environment node
+ *
+ * Public v2 row lookup. The wire is column-NAME keyed both ways: the predicate
+ * and sort translate down to storage ids on the way in, and the matched column
+ * id translates back to its name on the way out.
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockResolveWorkspaceScope,
+ mockCheckAccess,
+ mockFindRowMatches,
+ mockPredicateToFilter,
+ mockValidateSortSpec,
+ mockSortSpecNamesToIds,
+ mockGateError,
+ TableQueryValidationError,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceScope: vi.fn(),
+ mockCheckAccess: vi.fn(),
+ mockFindRowMatches: vi.fn(),
+ mockPredicateToFilter: vi.fn(),
+ mockValidateSortSpec: vi.fn(),
+ mockSortSpecNamesToIds: vi.fn(),
+ mockGateError: vi.fn(),
+ TableQueryValidationError: class TableQueryValidationError extends Error {},
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceScope: mockResolveWorkspaceScope,
+}))
+
+vi.mock('@/app/api/table/utils', () => ({
+ checkAccess: mockCheckAccess,
+ normalizeColumn: (col: Record) => col,
+ rootErrorMessage: (error: unknown) => String(error),
+ rowWriteErrorResponse: () => null,
+}))
+
+vi.mock('@/app/api/v2/tables/utils', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ v2BulkPredicateToFilter: mockPredicateToFilter,
+}))
+
+vi.mock('@/lib/table', () => ({
+ buildIdByName: vi.fn().mockReturnValue({ status: 'col-1', name: 'col-2' }),
+ sortSpecNamesToIds: mockSortSpecNamesToIds,
+}))
+vi.mock('@/lib/table/rows/service', () => ({ findRowMatches: mockFindRowMatches }))
+vi.mock('@/lib/table/query-builder/validate', () => ({ validateSortSpec: mockValidateSortSpec }))
+vi.mock('@/lib/table/errors', () => ({ TableQueryValidationError }))
+vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError }))
+
+import { POST } from '@/app/api/v2/tables/[tableId]/rows/find/route'
+
+const COLUMNS = [
+ { id: 'col-1', name: 'status', type: 'string' },
+ { id: 'col-2', name: 'name', type: 'string' },
+]
+const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: COLUMNS } }
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ workspaceId: 'ws-1',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2026-01-01T01:00:00Z'),
+}
+
+function callPost(body: unknown) {
+ const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/rows/find', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) })
+}
+
+describe('POST /api/v2/tables/[tableId]/rows/find', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceScope.mockResolvedValue(null)
+ mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE })
+ mockFindRowMatches.mockResolvedValue({
+ matches: [{ ordinal: 3, rowId: 'row-1', column: 'col-2' }],
+ truncated: false,
+ })
+ mockSortSpecNamesToIds.mockImplementation((spec: { field: string }[]) =>
+ spec.map((s) => ({ ...s, field: s.field === 'name' ? 'col-2' : s.field }))
+ )
+ mockGateError.mockResolvedValue(null)
+ })
+
+ it('reports the matched column by NAME, not its storage id', async () => {
+ const res = await callPost({ workspaceId: 'ws-1', q: 'acme' })
+
+ expect(res.status).toBe(200)
+ expect((await res.json()).data).toEqual({
+ matches: [{ ordinal: 3, rowId: 'row-1', column: 'name' }],
+ truncated: false,
+ })
+ expect(mockFindRowMatches).toHaveBeenCalledWith(
+ TABLE,
+ { q: 'acme', filter: undefined, sort: undefined },
+ expect.any(String)
+ )
+ })
+
+ it('translates the predicate and sort to storage keys before searching', async () => {
+ mockPredicateToFilter.mockReturnValue({ 'col-1': { $eq: 'active' } })
+ const predicate = { all: [{ field: 'status', op: 'eq', value: 'active' }] }
+
+ const res = await callPost({
+ workspaceId: 'ws-1',
+ q: 'acme',
+ predicate,
+ sort: [{ field: 'name', direction: 'asc' }],
+ })
+
+ expect(res.status).toBe(200)
+ expect(mockPredicateToFilter).toHaveBeenCalledWith(predicate, TABLE.schema)
+ expect(mockValidateSortSpec).toHaveBeenCalledWith(
+ [{ field: 'name', direction: 'asc' }],
+ COLUMNS
+ )
+ expect(mockFindRowMatches).toHaveBeenCalledWith(
+ TABLE,
+ { q: 'acme', filter: { 'col-1': { $eq: 'active' } }, sort: { 'col-2': 'asc' } },
+ expect.any(String)
+ )
+ })
+
+ it('surfaces truncation so a caller narrows instead of paging', async () => {
+ mockFindRowMatches.mockResolvedValue({ matches: [], truncated: true })
+
+ const res = await callPost({ workspaceId: 'ws-1', q: 'a' })
+
+ expect((await res.json()).data).toEqual({ matches: [], truncated: true })
+ })
+
+ it('400s an unresolvable predicate field instead of returning zero matches', async () => {
+ mockPredicateToFilter.mockImplementation(() => {
+ throw new TableQueryValidationError('Unknown column "nope"')
+ })
+
+ const res = await callPost({
+ workspaceId: 'ws-1',
+ q: 'acme',
+ predicate: { all: [{ field: 'nope', op: 'eq', value: 1 }] },
+ })
+
+ expect(res.status).toBe(400)
+ expect(mockFindRowMatches).not.toHaveBeenCalled()
+ })
+
+ it('400s an empty search string', async () => {
+ const res = await callPost({ workspaceId: 'ws-1', q: '' })
+
+ expect(res.status).toBe(400)
+ expect(mockFindRowMatches).not.toHaveBeenCalled()
+ })
+
+ it('masks a permission failure as 404 so table existence never leaks', async () => {
+ mockCheckAccess.mockResolvedValue({ ok: false, status: 403 })
+
+ const res = await callPost({ workspaceId: 'ws-1', q: 'acme' })
+
+ expect(res.status).toBe(404)
+ expect(mockFindRowMatches).not.toHaveBeenCalled()
+ })
+
+ it('404s with the gate off, before any work', async () => {
+ mockGateError.mockResolvedValue(
+ new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), {
+ status: 404,
+ })
+ )
+
+ const res = await callPost({ workspaceId: 'ws-1', q: 'acme' })
+
+ expect(res.status).toBe(404)
+ expect(mockCheckAccess).not.toHaveBeenCalled()
+ expect(mockFindRowMatches).not.toHaveBeenCalled()
+ })
+
+ it('429s a throttled caller', async () => {
+ mockCheckRateLimit.mockResolvedValue({
+ ...RATE_LIMIT_OK,
+ allowed: false,
+ remaining: 0,
+ retryAfterMs: 1000,
+ })
+
+ const res = await callPost({ workspaceId: 'ws-1', q: 'acme' })
+
+ expect(res.status).toBe(429)
+ expect(mockFindRowMatches).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts
new file mode 100644
index 00000000000..68d86f3dea5
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts
@@ -0,0 +1,116 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { v2FindTableRowsContract } from '@/lib/api/contracts/v2/tables'
+import { isZodError, parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import type { Filter, Sort, TableSchema } from '@/lib/table'
+import { buildIdByName, sortSpecNamesToIds } from '@/lib/table'
+import { TableQueryValidationError } from '@/lib/table/errors'
+import { validateSortSpec } from '@/lib/table/query-builder/validate'
+import { findRowMatches } from '@/lib/table/rows/service'
+import { checkAccess } from '@/app/api/table/utils'
+import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+import { columnNameById, v2BulkPredicateToFilter } from '@/app/api/v2/tables/utils'
+
+const logger = createLogger('V2TableRowsFindAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+interface TableRouteParams {
+ params: Promise<{ tableId: string }>
+}
+
+/**
+ * POST /api/v2/tables/[tableId]/rows/find — Case-insensitive substring search
+ * across every cell, narrowed by the same predicate/sort grammar as
+ * `POST /query`.
+ *
+ * Returns matching CELLS, not rows: each match carries the row's ordinal in the
+ * same filtered+sorted view a `POST /query` with these arguments would return,
+ * so a caller can jump straight to the page holding it.
+ */
+export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-rows-find')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2FindTableRowsContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId } = parsed.data.params
+ const { workspaceId, q, predicate, sort } = parsed.data.body
+
+ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const accessResult = await checkAccess(tableId, userId, 'read')
+ // Mask not-authorized and not-found alike so cross-workspace existence never leaks.
+ if (!accessResult.ok || accessResult.table.workspaceId !== workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ const { table } = accessResult
+ const schema = table.schema as TableSchema
+
+ // The public wire is column-NAME keyed both ways: translate the predicate
+ // and sort down to storage ids on the way in, and the matched column id
+ // back to its name on the way out.
+ let filter: Filter | undefined
+ if (predicate) filter = v2BulkPredicateToFilter(predicate, schema)
+
+ let sortObj: Sort | undefined
+ if (sort?.length) {
+ validateSortSpec(sort, schema.columns)
+ const storageSort = sortSpecNamesToIds(sort, buildIdByName(schema))
+ sortObj = Object.fromEntries(storageSort.map((s) => [s.field, s.direction]))
+ }
+
+ const { matches, truncated } = await findRowMatches(
+ table,
+ { q, filter, sort: sortObj },
+ requestId
+ )
+
+ const toColumnName = columnNameById(schema)
+
+ return v2Data(
+ {
+ matches: matches.map((match) => ({
+ ordinal: match.ordinal,
+ rowId: match.rowId,
+ column: toColumnName(match.column),
+ })),
+ truncated,
+ },
+ { rateLimit }
+ )
+ } catch (error) {
+ if (isZodError(error)) return v2ValidationError(error)
+ if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message)
+
+ logger.error(`[${requestId}] Error finding rows`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts
new file mode 100644
index 00000000000..a2677af979c
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts
@@ -0,0 +1,420 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest, NextResponse } from 'next/server'
+import type { V1BatchInsertTableRowsBody } from '@/lib/api/contracts/v1/tables'
+import {
+ v2CreateTableRowsContract,
+ v2DeleteTableRowsContract,
+ v2ListTableRowsContract,
+ v2UpdateRowsByFilterContract,
+} from '@/lib/api/contracts/v2/tables'
+import { isZodError, parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import type { RowData, TableSchema } from '@/lib/table'
+import {
+ batchInsertRows,
+ buildIdByName,
+ deleteRowsByFilter,
+ deleteRowsByIds,
+ insertRow,
+ rowDataNameToId,
+ updateRowsByFilter,
+ validateBatchRows,
+ validateRowData,
+ validateRowSize,
+} from '@/lib/table'
+import { namedRowMapper } from '@/lib/table/cell-format'
+import { TableQueryValidationError } from '@/lib/table/errors'
+import { queryRows } from '@/lib/table/rows/service'
+import { checkAccess } from '@/app/api/table/utils'
+import {
+ checkRateLimit,
+ type RateLimitResult,
+ resolveWorkspaceScope,
+} from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ decodeCursor,
+ encodeCursor,
+ v2CursorList,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+import {
+ toApiRow,
+ v2BulkPredicateToFilter,
+ v2RowValidationError,
+ v2RowWriteError,
+ v2TableAccessError,
+} from '@/app/api/v2/tables/utils'
+
+const logger = createLogger('V2TableRowsAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+interface TableRowsRouteParams {
+ params: Promise<{ tableId: string }>
+}
+
+/**
+ * Inserts a validated batch of rows. Authorizes against the table's own
+ * workspace (IDOR guard) before any write, translates name-keyed row data to
+ * storage ids, and returns the inserted rows in the canonical v2 envelope.
+ */
+async function handleBatchInsert(
+ requestId: string,
+ tableId: string,
+ validated: V1BatchInsertTableRowsBody,
+ userId: string,
+ rateLimit: RateLimitResult
+): Promise {
+ const accessResult = await checkAccess(tableId, userId, 'write')
+ if (!accessResult.ok) return v2TableAccessError(accessResult)
+
+ const { table } = accessResult
+ if (validated.workspaceId !== table.workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ // External callers key row data by column name; storage keys by id.
+ const idByName = buildIdByName(table.schema as TableSchema)
+ const toNamedRow = namedRowMapper((table.schema as TableSchema).columns)
+ const rows = (validated.rows as RowData[]).map((r) => rowDataNameToId(r, idByName))
+
+ const validation = await validateBatchRows({
+ rows,
+ schema: table.schema as TableSchema,
+ tableId,
+ })
+ if (!validation.valid) return v2RowValidationError(validation.response)
+
+ try {
+ const insertedRows = await batchInsertRows(
+ { tableId, rows, workspaceId: validated.workspaceId, userId },
+ table,
+ requestId
+ )
+
+ return v2Data(
+ {
+ rows: insertedRows.map((r) => toApiRow(r, toNamedRow)),
+ insertedCount: insertedRows.length,
+ },
+ { rateLimit }
+ )
+ } catch (error) {
+ const response = v2RowWriteError(error)
+ if (response) return response
+
+ logger.error(`[${requestId}] Error batch inserting rows`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+}
+
+/**
+ * GET /api/v2/tables/[tableId]/rows — Plain cursor page over the default row
+ * order. Filtered/sorted reads go through `POST /query`.
+ */
+export const GET = withRouteHandler(async (request: NextRequest, context: TableRowsRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-rows')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2ListTableRowsContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId } = parsed.data.params
+ const validated = parsed.data.query
+
+ const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const accessResult = await checkAccess(tableId, userId, 'read')
+ // Mask not-authorized and not-found alike so cross-workspace existence never leaks.
+ if (!accessResult.ok) return v2Error('NOT_FOUND', 'Table not found')
+
+ const { table } = accessResult
+ if (validated.workspaceId !== table.workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ const toNamedRow = namedRowMapper((table.schema as TableSchema).columns)
+
+ // Cursor-uniform v2 pagination: the opaque cursor encodes the underlying
+ // offset (upgradeable to keyset later without an interface change). Total row
+ // count is intentionally omitted here — it's available as `rowCount` on the table.
+ const offset = validated.cursor
+ ? (decodeCursor<{ offset: number }>(validated.cursor)?.offset ?? 0)
+ : 0
+
+ const result = await queryRows(
+ table,
+ {
+ limit: validated.limit,
+ offset,
+ includeTotal: true,
+ withExecutions: false,
+ },
+ requestId
+ )
+
+ const total = result.totalCount ?? 0
+ const hasMore = offset + result.rowCount < total
+ const nextCursor = hasMore ? encodeCursor({ offset: offset + validated.limit }) : null
+
+ return v2CursorList(
+ result.rows.map((r) => toApiRow(r, toNamedRow)),
+ nextCursor,
+ { rateLimit }
+ )
+ } catch (error) {
+ if (isZodError(error)) return v2ValidationError(error)
+ if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message)
+
+ logger.error(`[${requestId}] Error querying rows`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/** POST /api/v2/tables/[tableId]/rows — Insert row(s). Supports single or batch. */
+export const POST = withRouteHandler(
+ async (request: NextRequest, context: TableRowsRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-rows')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2CreateTableRowsContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId } = parsed.data.params
+
+ if ('rows' in parsed.data.body) {
+ const batchValidated = parsed.data.body
+ const scopeError = await resolveWorkspaceScope(rateLimit, batchValidated.workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+ return handleBatchInsert(requestId, tableId, batchValidated, userId, rateLimit)
+ }
+
+ const validated = parsed.data.body
+ const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const accessResult = await checkAccess(tableId, userId, 'write')
+ if (!accessResult.ok) return v2TableAccessError(accessResult)
+
+ const { table } = accessResult
+ if (validated.workspaceId !== table.workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ const idByName = buildIdByName(table.schema as TableSchema)
+ const toNamedRow = namedRowMapper((table.schema as TableSchema).columns)
+ const rowData = rowDataNameToId(validated.data as RowData, idByName)
+
+ const validation = await validateRowData({
+ rowData,
+ schema: table.schema as TableSchema,
+ tableId,
+ })
+ if (!validation.valid) return v2RowValidationError(validation.response)
+
+ const row = await insertRow(
+ { tableId, data: rowData, workspaceId: validated.workspaceId, userId },
+ table,
+ requestId
+ )
+
+ return v2Data({ row: toApiRow(row, toNamedRow) }, { rateLimit })
+ } catch (error) {
+ if (isZodError(error)) return v2ValidationError(error)
+
+ const response = v2RowWriteError(error)
+ if (response) return response
+
+ logger.error(`[${requestId}] Error inserting row`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
+
+/** PUT /api/v2/tables/[tableId]/rows — Bulk update rows by predicate filter. */
+export const PUT = withRouteHandler(async (request: NextRequest, context: TableRowsRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-rows')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2UpdateRowsByFilterContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId } = parsed.data.params
+ const validated = parsed.data.body
+
+ const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const accessResult = await checkAccess(tableId, userId, 'write')
+ if (!accessResult.ok) return v2TableAccessError(accessResult)
+
+ const { table } = accessResult
+ if (validated.workspaceId !== table.workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ const idByName = buildIdByName(table.schema as TableSchema)
+ const patchData = rowDataNameToId(validated.data as RowData, idByName)
+
+ const sizeValidation = validateRowSize(patchData)
+ if (!sizeValidation.valid) {
+ return v2Error('BAD_REQUEST', 'Invalid row data', { details: sizeValidation.errors })
+ }
+
+ const result = await updateRowsByFilter(
+ table,
+ {
+ filter: v2BulkPredicateToFilter(validated.filter, table.schema as TableSchema),
+ data: patchData,
+ limit: validated.limit,
+ actorUserId: userId,
+ },
+ requestId
+ )
+
+ // v2 always returns `updatedRowIds` ([] when nothing matched); v1 dropped it
+ // on the zero-match branch.
+ return v2Data(
+ { updatedCount: result.affectedCount, updatedRowIds: result.affectedRowIds },
+ { rateLimit }
+ )
+ } catch (error) {
+ if (isZodError(error)) return v2ValidationError(error)
+ if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message)
+
+ const response = v2RowWriteError(error)
+ if (response) return response
+
+ logger.error(`[${requestId}] Error updating rows by filter`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/** DELETE /api/v2/tables/[tableId]/rows — Delete rows by predicate filter or IDs. */
+export const DELETE = withRouteHandler(
+ async (request: NextRequest, context: TableRowsRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-rows')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2DeleteTableRowsContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId } = parsed.data.params
+ const validated = parsed.data.body
+
+ const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const accessResult = await checkAccess(tableId, userId, 'write')
+ if (!accessResult.ok) return v2TableAccessError(accessResult)
+
+ const { table } = accessResult
+ if (validated.workspaceId !== table.workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ // id-based and filter-based deletes share one envelope; `requestedCount`/
+ // `missingRowIds` are populated only for the id-based delete (which has a
+ // requested set) and omitted for the filter-based delete.
+ if (validated.rowIds) {
+ const result = await deleteRowsByIds(
+ table,
+ { tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId },
+ requestId
+ )
+
+ return v2Data(
+ {
+ deletedCount: result.deletedCount,
+ deletedRowIds: result.deletedRowIds,
+ requestedCount: result.requestedCount,
+ missingRowIds: result.missingRowIds,
+ },
+ { rateLimit }
+ )
+ }
+
+ const result = await deleteRowsByFilter(
+ table,
+ {
+ filter: v2BulkPredicateToFilter(validated.filter!, table.schema as TableSchema),
+ limit: validated.limit,
+ },
+ requestId
+ )
+
+ return v2Data(
+ { deletedCount: result.affectedCount, deletedRowIds: result.affectedRowIds },
+ { rateLimit }
+ )
+ } catch (error) {
+ if (isZodError(error)) return v2ValidationError(error)
+ if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message)
+
+ const response = v2RowWriteError(error)
+ if (response) return response
+
+ logger.error(`[${requestId}] Error deleting rows`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts
new file mode 100644
index 00000000000..a8b4c21593b
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts
@@ -0,0 +1,94 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { v2UpsertTableRowContract } from '@/lib/api/contracts/v2/tables'
+import { isZodError, parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import type { RowData, TableSchema } from '@/lib/table'
+import { buildIdByName, rowDataNameToId, upsertRow } from '@/lib/table'
+import { namedRowMapper } from '@/lib/table/cell-format'
+import { checkAccess } from '@/app/api/table/utils'
+import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CaughtOrchestrationError,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+import { toApiRow, v2TableAccessError } from '@/app/api/v2/tables/utils'
+
+const logger = createLogger('V2TableUpsertAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+interface UpsertRouteParams {
+ params: Promise<{ tableId: string }>
+}
+
+/** POST /api/v2/tables/[tableId]/rows/upsert — Insert or update a row based on unique columns. */
+export const POST = withRouteHandler(async (request: NextRequest, context: UpsertRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-rows')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2UpsertTableRowContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId } = parsed.data.params
+ const validated = parsed.data.body
+
+ const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const result = await checkAccess(tableId, userId, 'write')
+ if (!result.ok) return v2TableAccessError(result)
+
+ const { table } = result
+ if (table.workspaceId !== validated.workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ const idByName = buildIdByName(table.schema as TableSchema)
+ const toNamedRow = namedRowMapper((table.schema as TableSchema).columns)
+ const upsertResult = await upsertRow(
+ {
+ tableId,
+ workspaceId: validated.workspaceId,
+ data: rowDataNameToId(validated.data as RowData, idByName),
+ userId,
+ conflictTarget: validated.conflictTarget,
+ },
+ table,
+ requestId
+ )
+
+ return v2Data(
+ { row: toApiRow(upsertResult.row, toNamedRow), operation: upsertResult.operation },
+ { rateLimit }
+ )
+ } catch (error) {
+ if (isZodError(error)) return v2ValidationError(error)
+
+ const classified = v2CaughtOrchestrationError(error)
+ if (classified) return classified
+
+ logger.error(`[${requestId}] Error upserting row`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts
new file mode 100644
index 00000000000..25488f0ad26
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts
@@ -0,0 +1,240 @@
+/**
+ * @vitest-environment node
+ *
+ * Public v2 saved-view detail: read, patch, delete. A view that is not on this
+ * table is a 404 rather than a silent no-op, so a caller can tell a wrong id
+ * from a successful write.
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockResolveWorkspaceScope,
+ mockCheckAccess,
+ mockGetTableView,
+ mockUpdateTableView,
+ mockDeleteTableView,
+ mockGateError,
+ TableViewValidationError,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceScope: vi.fn(),
+ mockCheckAccess: vi.fn(),
+ mockGetTableView: vi.fn(),
+ mockUpdateTableView: vi.fn(),
+ mockDeleteTableView: vi.fn(),
+ mockGateError: vi.fn(),
+ TableViewValidationError: class TableViewValidationError extends Error {},
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceScope: mockResolveWorkspaceScope,
+}))
+
+vi.mock('@/app/api/table/utils', () => ({
+ checkAccess: mockCheckAccess,
+ normalizeColumn: (col: Record) => col,
+ rootErrorMessage: (error: unknown) => String(error),
+ rowWriteErrorResponse: () => null,
+}))
+
+vi.mock('@/lib/table', () => ({
+ getTableView: mockGetTableView,
+ updateTableView: mockUpdateTableView,
+ deleteTableView: mockDeleteTableView,
+ TableViewValidationError,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError }))
+
+import { DELETE, GET, PATCH } from '@/app/api/v2/tables/[tableId]/views/[viewId]/route'
+
+const COLUMNS = [{ id: 'col-1', name: 'status', type: 'string' }]
+const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: COLUMNS } }
+const VIEW = {
+ id: 'view-1',
+ tableId: 'table-1',
+ name: 'Active',
+ config: {},
+ isDefault: false,
+ createdBy: 'user-1',
+ createdAt: new Date('2026-01-01T00:00:00Z'),
+ updatedAt: new Date('2026-01-02T00:00:00Z'),
+}
+const API_VIEW = {
+ ...VIEW,
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+}
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ workspaceId: 'ws-1',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2026-01-01T01:00:00Z'),
+}
+
+const params = { params: Promise.resolve({ tableId: 'table-1', viewId: 'view-1' }) }
+
+function callGet() {
+ return GET(
+ new NextRequest('http://localhost:3000/api/v2/tables/table-1/views/view-1?workspaceId=ws-1', {
+ method: 'GET',
+ }),
+ params
+ )
+}
+
+function callPatch(body: unknown) {
+ return PATCH(
+ new NextRequest('http://localhost:3000/api/v2/tables/table-1/views/view-1', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ }),
+ params
+ )
+}
+
+function callDelete() {
+ return DELETE(
+ new NextRequest('http://localhost:3000/api/v2/tables/table-1/views/view-1?workspaceId=ws-1', {
+ method: 'DELETE',
+ }),
+ params
+ )
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceScope.mockResolvedValue(null)
+ mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE })
+ mockGateError.mockResolvedValue(null)
+})
+
+describe('GET /api/v2/tables/[tableId]/views/[viewId]', () => {
+ it('returns the view scoped to its table', async () => {
+ mockGetTableView.mockResolvedValue(VIEW)
+
+ const res = await callGet()
+
+ expect(res.status).toBe(200)
+ expect((await res.json()).data).toEqual({ view: API_VIEW })
+ expect(mockGetTableView).toHaveBeenCalledWith('view-1', 'table-1', COLUMNS)
+ })
+
+ it('404s a view id that belongs to a different table', async () => {
+ mockGetTableView.mockResolvedValue(null)
+
+ const res = await callGet()
+
+ expect(res.status).toBe(404)
+ expect((await res.json()).error.message).toBe('View not found')
+ })
+
+ it('429s a throttled caller', async () => {
+ mockCheckRateLimit.mockResolvedValue({
+ ...RATE_LIMIT_OK,
+ allowed: false,
+ remaining: 0,
+ retryAfterMs: 1000,
+ })
+
+ const res = await callGet()
+
+ expect(res.status).toBe(429)
+ expect(mockGetTableView).not.toHaveBeenCalled()
+ })
+})
+
+describe('PATCH /api/v2/tables/[tableId]/views/[viewId]', () => {
+ it('forwards the patch fields to the service', async () => {
+ mockUpdateTableView.mockResolvedValue({ ...VIEW, isDefault: true })
+
+ const res = await callPatch({ workspaceId: 'ws-1', isDefault: true })
+
+ expect(res.status).toBe(200)
+ expect((await res.json()).data.view.isDefault).toBe(true)
+ expect(mockUpdateTableView).toHaveBeenCalledWith({
+ viewId: 'view-1',
+ tableId: 'table-1',
+ name: undefined,
+ config: undefined,
+ configPatch: undefined,
+ isDefault: true,
+ columns: COLUMNS,
+ })
+ })
+
+ it('400s a body that changes nothing', async () => {
+ const res = await callPatch({ workspaceId: 'ws-1' })
+
+ expect(res.status).toBe(400)
+ expect(mockUpdateTableView).not.toHaveBeenCalled()
+ })
+
+ it('400s config and configPatch together', async () => {
+ const res = await callPatch({ workspaceId: 'ws-1', config: {}, configPatch: {} })
+
+ expect(res.status).toBe(400)
+ expect(mockUpdateTableView).not.toHaveBeenCalled()
+ })
+
+ it('403s a read-only member', async () => {
+ mockCheckAccess.mockResolvedValue({ ok: false, status: 403 })
+
+ const res = await callPatch({ workspaceId: 'ws-1', isDefault: true })
+
+ expect(res.status).toBe(403)
+ expect(mockUpdateTableView).not.toHaveBeenCalled()
+ })
+
+ it('404s with the gate off, before any work', async () => {
+ mockGateError.mockResolvedValue(
+ new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), {
+ status: 404,
+ })
+ )
+
+ const res = await callPatch({ workspaceId: 'ws-1', isDefault: true })
+
+ expect(res.status).toBe(404)
+ expect(mockCheckAccess).not.toHaveBeenCalled()
+ expect(mockUpdateTableView).not.toHaveBeenCalled()
+ })
+})
+
+describe('DELETE /api/v2/tables/[tableId]/views/[viewId]', () => {
+ it('returns the deleted view id', async () => {
+ mockDeleteTableView.mockResolvedValue(true)
+
+ const res = await callDelete()
+
+ expect(res.status).toBe(200)
+ expect((await res.json()).data).toEqual({ id: 'view-1' })
+ expect(mockDeleteTableView).toHaveBeenCalledWith('view-1', 'table-1')
+ })
+
+ it('404s when nothing was deleted rather than reporting a phantom success', async () => {
+ mockDeleteTableView.mockResolvedValue(false)
+
+ const res = await callDelete()
+
+ expect(res.status).toBe(404)
+ })
+
+ it('403s a read-only member', async () => {
+ mockCheckAccess.mockResolvedValue({ ok: false, status: 403 })
+
+ const res = await callDelete()
+
+ expect(res.status).toBe(403)
+ expect(mockDeleteTableView).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts
new file mode 100644
index 00000000000..ba29f7665c0
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts
@@ -0,0 +1,183 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import {
+ v2DeleteTableViewContract,
+ v2GetTableViewContract,
+ v2UpdateTableViewContract,
+} from '@/lib/api/contracts/v2/tables'
+import { parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import type { TableSchema } from '@/lib/table'
+import {
+ deleteTableView,
+ getTableView,
+ TableViewValidationError,
+ updateTableView,
+} from '@/lib/table'
+import { checkAccess } from '@/app/api/table/utils'
+import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+import { toApiView, v2TableAccessError } from '@/app/api/v2/tables/utils'
+
+const logger = createLogger('V2TableViewDetailAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+interface TableViewRouteParams {
+ params: Promise<{ tableId: string; viewId: string }>
+}
+
+/** GET /api/v2/tables/[tableId]/views/[viewId] — One saved view. */
+export const GET = withRouteHandler(async (request: NextRequest, context: TableViewRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-view-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2GetTableViewContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId, viewId } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+
+ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const result = await checkAccess(tableId, userId, 'read')
+ // Mask not-authorized and not-found alike so cross-workspace existence never leaks.
+ if (!result.ok || result.table.workspaceId !== workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ const view = await getTableView(viewId, tableId, (result.table.schema as TableSchema).columns)
+ if (!view) return v2Error('NOT_FOUND', 'View not found')
+
+ return v2Data({ view: toApiView(view) }, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Error getting table view`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/**
+ * PATCH /api/v2/tables/[tableId]/views/[viewId] — Rename, replace or merge the
+ * config, or promote the view to the table's default.
+ */
+export const PATCH = withRouteHandler(
+ async (request: NextRequest, context: TableViewRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-view-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2UpdateTableViewContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId, viewId } = parsed.data.params
+ const { workspaceId, name, config, configPatch, isDefault } = parsed.data.body
+
+ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const result = await checkAccess(tableId, userId, 'write')
+ if (!result.ok) return v2TableAccessError(result)
+
+ if (result.table.workspaceId !== workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ const view = await updateTableView({
+ viewId,
+ tableId,
+ name,
+ config,
+ configPatch,
+ isDefault,
+ columns: (result.table.schema as TableSchema).columns,
+ })
+ if (!view) return v2Error('NOT_FOUND', 'View not found')
+
+ return v2Data({ view: toApiView(view) }, { rateLimit })
+ } catch (error) {
+ if (error instanceof TableViewValidationError) return v2Error('BAD_REQUEST', error.message)
+
+ logger.error(`[${requestId}] Error updating table view`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
+
+/** DELETE /api/v2/tables/[tableId]/views/[viewId] — Remove a saved view. */
+export const DELETE = withRouteHandler(
+ async (request: NextRequest, context: TableViewRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-view-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2DeleteTableViewContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId, viewId } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+
+ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const result = await checkAccess(tableId, userId, 'write')
+ if (!result.ok) return v2TableAccessError(result)
+
+ if (result.table.workspaceId !== workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ const deleted = await deleteTableView(viewId, tableId)
+ if (!deleted) return v2Error('NOT_FOUND', 'View not found')
+
+ return v2Data({ id: viewId }, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Error deleting table view`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts
new file mode 100644
index 00000000000..8a789e0de94
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts
@@ -0,0 +1,204 @@
+/**
+ * @vitest-environment node
+ *
+ * Public v2 saved views: list and create. A view is presentation state, so the
+ * read needs only `read` while saving one needs `write`.
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockResolveWorkspaceScope,
+ mockCheckAccess,
+ mockListTableViews,
+ mockCreateTableView,
+ mockGateError,
+ TableViewValidationError,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceScope: vi.fn(),
+ mockCheckAccess: vi.fn(),
+ mockListTableViews: vi.fn(),
+ mockCreateTableView: vi.fn(),
+ mockGateError: vi.fn(),
+ TableViewValidationError: class TableViewValidationError extends Error {},
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceScope: mockResolveWorkspaceScope,
+}))
+
+vi.mock('@/app/api/table/utils', () => ({
+ checkAccess: mockCheckAccess,
+ normalizeColumn: (col: Record) => col,
+ rootErrorMessage: (error: unknown) => String(error),
+ rowWriteErrorResponse: () => null,
+}))
+
+vi.mock('@/lib/table', () => ({
+ listTableViews: mockListTableViews,
+ createTableView: mockCreateTableView,
+ TableViewValidationError,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError }))
+
+import { GET, POST } from '@/app/api/v2/tables/[tableId]/views/route'
+
+const COLUMNS = [{ id: 'col-1', name: 'status', type: 'string' }]
+const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: COLUMNS } }
+const VIEW = {
+ id: 'view-1',
+ tableId: 'table-1',
+ name: 'Active',
+ config: { filter: { all: [{ field: 'col-1', op: 'eq', value: 'active' }] } },
+ isDefault: true,
+ createdBy: 'user-1',
+ createdAt: new Date('2026-01-01T00:00:00Z'),
+ updatedAt: new Date('2026-01-02T00:00:00Z'),
+}
+const API_VIEW = {
+ ...VIEW,
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+}
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ workspaceId: 'ws-1',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2026-01-01T01:00:00Z'),
+}
+
+function callGet() {
+ const req = new NextRequest(
+ 'http://localhost:3000/api/v2/tables/table-1/views?workspaceId=ws-1',
+ { method: 'GET' }
+ )
+ return GET(req, { params: Promise.resolve({ tableId: 'table-1' }) })
+}
+
+function callPost(body: unknown) {
+ const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/views', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) })
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceScope.mockResolvedValue(null)
+ mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE })
+ mockGateError.mockResolvedValue(null)
+})
+
+describe('GET /api/v2/tables/[tableId]/views', () => {
+ it('returns every view as one full page with ISO timestamps', async () => {
+ mockListTableViews.mockResolvedValue([VIEW])
+
+ const res = await callGet()
+
+ expect(res.status).toBe(200)
+ expect(await res.json()).toEqual({ data: [API_VIEW], nextCursor: null })
+ // The columns are passed so stale references are pruned from each config.
+ expect(mockListTableViews).toHaveBeenCalledWith('table-1', COLUMNS)
+ })
+
+ it('404s a table in another workspace without listing', async () => {
+ mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, workspaceId: 'ws-other' } })
+
+ const res = await callGet()
+
+ expect(res.status).toBe(404)
+ expect(mockListTableViews).not.toHaveBeenCalled()
+ })
+
+ it('masks a permission failure as 404 so table existence never leaks', async () => {
+ mockCheckAccess.mockResolvedValue({ ok: false, status: 403 })
+
+ const res = await callGet()
+
+ expect(res.status).toBe(404)
+ expect(mockListTableViews).not.toHaveBeenCalled()
+ })
+
+ it('429s a throttled caller', async () => {
+ mockCheckRateLimit.mockResolvedValue({
+ ...RATE_LIMIT_OK,
+ allowed: false,
+ remaining: 0,
+ retryAfterMs: 1000,
+ })
+
+ const res = await callGet()
+
+ expect(res.status).toBe(429)
+ expect(mockListTableViews).not.toHaveBeenCalled()
+ })
+})
+
+describe('POST /api/v2/tables/[tableId]/views', () => {
+ it('creates the view with the caller as author and answers 201', async () => {
+ mockCreateTableView.mockResolvedValue(VIEW)
+
+ const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} })
+
+ expect(res.status).toBe(201)
+ expect((await res.json()).data).toEqual({ view: API_VIEW })
+ expect(mockCreateTableView).toHaveBeenCalledWith({
+ tableId: 'table-1',
+ workspaceId: 'ws-1',
+ name: 'Active',
+ config: {},
+ userId: 'user-1',
+ columns: COLUMNS,
+ })
+ })
+
+ it('400s a blank view name without touching the service', async () => {
+ const res = await callPost({ workspaceId: 'ws-1', name: ' ', config: {} })
+
+ expect(res.status).toBe(400)
+ expect(mockCreateTableView).not.toHaveBeenCalled()
+ })
+
+ it('403s a read-only member', async () => {
+ mockCheckAccess.mockResolvedValue({ ok: false, status: 403 })
+
+ const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} })
+
+ expect(res.status).toBe(403)
+ expect(mockCreateTableView).not.toHaveBeenCalled()
+ })
+
+ it('404s with the gate off, before any work', async () => {
+ mockGateError.mockResolvedValue(
+ new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), {
+ status: 404,
+ })
+ )
+
+ const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} })
+
+ expect(res.status).toBe(404)
+ expect(mockCheckAccess).not.toHaveBeenCalled()
+ expect(mockCreateTableView).not.toHaveBeenCalled()
+ })
+
+ it('surfaces a service-level view validation failure as 400', async () => {
+ mockCreateTableView.mockRejectedValue(new TableViewValidationError('View name cannot be empty'))
+
+ const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} })
+
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.message).toBe('View name cannot be empty')
+ })
+})
diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/route.ts b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts
new file mode 100644
index 00000000000..be0dcbe0fa7
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts
@@ -0,0 +1,127 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { v2CreateTableViewContract, v2ListTableViewsContract } from '@/lib/api/contracts/v2/tables'
+import { parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import type { TableSchema } from '@/lib/table'
+import { createTableView, listTableViews, TableViewValidationError } from '@/lib/table'
+import { checkAccess } from '@/app/api/table/utils'
+import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CursorList,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+import { toApiView, v2TableAccessError } from '@/app/api/v2/tables/utils'
+
+const logger = createLogger('V2TableViewsAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+interface TableRouteParams {
+ params: Promise<{ tableId: string }>
+}
+
+/**
+ * GET /api/v2/tables/[tableId]/views — Every saved view on the table.
+ *
+ * A table carries a bounded set of views, so this is one full page and
+ * `nextCursor` is always `null`.
+ */
+export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-views')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2ListTableViewsContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId } = parsed.data.params
+ const { workspaceId } = parsed.data.query
+
+ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const result = await checkAccess(tableId, userId, 'read')
+ // Mask not-authorized and not-found alike so cross-workspace existence never leaks.
+ if (!result.ok || result.table.workspaceId !== workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ const views = await listTableViews(tableId, (result.table.schema as TableSchema).columns)
+
+ return v2CursorList(views.map(toApiView), null, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Error listing table views`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/** POST /api/v2/tables/[tableId]/views — Save a filter/sort/layout as a named view. */
+export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-views')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2CreateTableViewContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { tableId } = parsed.data.params
+ const { workspaceId, name, config } = parsed.data.body
+
+ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+
+ const result = await checkAccess(tableId, userId, 'write')
+ if (!result.ok) return v2TableAccessError(result)
+
+ if (result.table.workspaceId !== workspaceId) {
+ return v2Error('NOT_FOUND', 'Table not found')
+ }
+
+ const view = await createTableView({
+ tableId,
+ workspaceId,
+ name,
+ config,
+ userId,
+ columns: (result.table.schema as TableSchema).columns,
+ })
+
+ return v2Data({ view: toApiView(view) }, { rateLimit, status: 201 })
+ } catch (error) {
+ if (error instanceof TableViewValidationError) return v2Error('BAD_REQUEST', error.message)
+
+ logger.error(`[${requestId}] Error creating table view`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts b/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts
new file mode 100644
index 00000000000..87268ab070f
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts
@@ -0,0 +1,69 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { v2TableExportDownloadContract } from '@/lib/api/contracts/v2/tables'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { requireTableExport, tableExportResult } from '@/lib/table/orchestration/export-resource'
+import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service'
+import { checkAccess } from '@/app/api/table/utils'
+import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CaughtOrchestrationError,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2TableExportDownloadAPI')
+const DOWNLOAD_TTL_SECONDS = 60 * 60
+
+interface TableExportRouteParams {
+ params: Promise<{ exportId: string }>
+}
+
+export const GET = withRouteHandler(
+ async (request: NextRequest, context: TableExportRouteParams) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-export')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(v2TableExportDownloadContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+ const { workspaceId } = parsed.data.query
+ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+ const record = await requireTableExport(parsed.data.params.exportId, workspaceId)
+ const access = await checkAccess(record.tableId, userId, 'read')
+ if (!access.ok || access.table.workspaceId !== workspaceId) {
+ return v2Error('NOT_FOUND', 'Table export not found')
+ }
+ const result = tableExportResult(record)
+ const url = await generatePresignedDownloadUrl(
+ result.resultKey,
+ 'workspace',
+ DOWNLOAD_TTL_SECONDS
+ )
+ return v2Data(
+ {
+ url,
+ fileName: result.resultKey.split('/').pop() ?? `export.${result.format}`,
+ expiresAt: new Date(Date.now() + DOWNLOAD_TTL_SECONDS * 1000).toISOString(),
+ },
+ { rateLimit }
+ )
+ } catch (error) {
+ const classified = v2CaughtOrchestrationError(error)
+ if (classified) return classified
+ logger.error('Failed to issue table export download', { error: getErrorMessage(error) })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts b/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts
new file mode 100644
index 00000000000..4fa1032e782
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts
@@ -0,0 +1,92 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import {
+ v2CancelTableExportContract,
+ v2GetTableExportContract,
+} from '@/lib/api/contracts/v2/tables'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ cancelTableExportResource,
+ requireTableExport,
+ toV2TableExport,
+} from '@/lib/table/orchestration/export-resource'
+import { checkAccess } from '@/app/api/table/utils'
+import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CaughtOrchestrationError,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2TableExportAPI')
+
+interface TableExportRouteParams {
+ params: Promise<{ exportId: string }>
+}
+
+async function authorizeExport(exportId: string, workspaceId: string, userId: string) {
+ const record = await requireTableExport(exportId, workspaceId)
+ const access = await checkAccess(record.tableId, userId, 'read')
+ if (!access.ok || access.table.workspaceId !== workspaceId) return null
+ return record
+}
+
+export const GET = withRouteHandler(
+ async (request: NextRequest, context: TableExportRouteParams) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-export')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(v2GetTableExportContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+ const { workspaceId } = parsed.data.query
+ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+ const record = await authorizeExport(parsed.data.params.exportId, workspaceId, userId)
+ if (!record) return v2Error('NOT_FOUND', 'Table export not found')
+ return v2Data(toV2TableExport(record), { rateLimit })
+ } catch (error) {
+ const classified = v2CaughtOrchestrationError(error)
+ if (classified) return classified
+ logger.error('Failed to read table export', { error: getErrorMessage(error) })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
+
+export const DELETE = withRouteHandler(
+ async (request: NextRequest, context: TableExportRouteParams) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-export')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(v2CancelTableExportContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+ const { workspaceId } = parsed.data.query
+ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+ const record = await authorizeExport(parsed.data.params.exportId, workspaceId, userId)
+ if (!record) return v2Error('NOT_FOUND', 'Table export not found')
+ return v2Data(toV2TableExport(await cancelTableExportResource(record)), { rateLimit })
+ } catch (error) {
+ const classified = v2CaughtOrchestrationError(error)
+ if (classified) return classified
+ logger.error('Failed to cancel table export', { error: getErrorMessage(error) })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/tables/folders/route.ts b/apps/sim/app/api/v2/tables/folders/route.ts
new file mode 100644
index 00000000000..c9744bf395b
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/folders/route.ts
@@ -0,0 +1,182 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import {
+ v2CreateTableFolderContract,
+ v2DeleteTableFolderContract,
+ v2ListTableFoldersContract,
+ v2RelocateTableFolderContract,
+} from '@/lib/api/contracts/v2/tables'
+import { parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ createFolderAtPath,
+ deleteFolderByPath,
+ relocateFolderByPath,
+} from '@/lib/folders/orchestration'
+import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import {
+ resolveFolderPathId,
+ toV2PathFolder,
+ v2FolderPathMutationError,
+} from '@/app/api/v2/lib/folders'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CursorList,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2TableFoldersAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+export const GET = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+ try {
+ const rateLimit = await checkRateLimit(request, 'tables')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(
+ v2ListTableFoldersContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+ const { workspaceId, parentPath, search, sortBy, sortOrder } = parsed.data.query
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const index = await loadActiveFolderPathIndex(workspaceId, 'table')
+ const parentId = parentPath === undefined ? undefined : resolveFolderPathId(index, parentPath)
+ if (parentPath !== undefined && parentId === undefined) {
+ return v2Error('NOT_FOUND', 'Folder not found')
+ }
+ const rows = await listActiveFolderRows(workspaceId, 'table', {
+ parentId,
+ search,
+ sortBy,
+ sortOrder,
+ })
+ return v2CursorList(
+ rows.map((row) => toV2PathFolder(row, index, false)),
+ null,
+ { rateLimit }
+ )
+ } catch (error) {
+ logger.error(`[${requestId}] Error listing table folders`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const rateLimit = await checkRateLimit(request, 'tables')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(
+ v2CreateTableFolderContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+ const { workspaceId, path } = parsed.data.body
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+ const result = await createFolderAtPath({ resourceType: 'table', workspaceId, userId, path })
+ if (!result.success || !result.folder) {
+ return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder')
+ }
+ const index = await loadActiveFolderPathIndex(workspaceId, 'table')
+ return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit, status: 201 })
+})
+
+export const PATCH = withRouteHandler(async (request: NextRequest) => {
+ const rateLimit = await checkRateLimit(request, 'tables')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(
+ v2RelocateTableFolderContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+ const { workspaceId, path, destinationPath } = parsed.data.body
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+ const result = await relocateFolderByPath({
+ resourceType: 'table',
+ workspaceId,
+ userId,
+ path,
+ destinationPath,
+ })
+ if (!result.success || !result.folder) {
+ return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder')
+ }
+ const index = await loadActiveFolderPathIndex(workspaceId, 'table')
+ return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit })
+})
+
+export const DELETE = withRouteHandler(async (request: NextRequest) => {
+ const rateLimit = await checkRateLimit(request, 'tables')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(
+ v2DeleteTableFolderContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+ const { workspaceId, path, recursive } = parsed.data.query
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+ const result = await deleteFolderByPath({
+ resourceType: 'table',
+ workspaceId,
+ userId,
+ path,
+ recursive,
+ })
+ if (!result.success || !result.deletedItems) {
+ return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder')
+ }
+ return v2Data(
+ {
+ path,
+ deleted: true as const,
+ deletedItems: {
+ folders: result.deletedItems.folders,
+ tables: result.deletedItems.tables ?? 0,
+ },
+ },
+ { rateLimit }
+ )
+})
diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts
new file mode 100644
index 00000000000..137221ed511
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts
@@ -0,0 +1,136 @@
+/**
+ * @vitest-environment node
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockResolveWorkspaceScope,
+ mockGetOwnedTableImportUpload,
+ mockFindOwnedTableImport,
+ mockStartUploadedTableImport,
+ mockToV2TableImport,
+ mockCompleteUploadSession,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceScope: vi.fn(),
+ mockGetOwnedTableImportUpload: vi.fn(),
+ mockFindOwnedTableImport: vi.fn(),
+ mockStartUploadedTableImport: vi.fn(),
+ mockToV2TableImport: vi.fn(),
+ mockCompleteUploadSession: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceScope: mockResolveWorkspaceScope,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+vi.mock('@/app/api/v2/tables/utils', () => ({
+ v2TableLockError: vi.fn().mockReturnValue(null),
+}))
+
+vi.mock('@/lib/table/orchestration/import-resource', () => ({
+ findOwnedTableImport: mockFindOwnedTableImport,
+ getOwnedTableImportUpload: mockGetOwnedTableImportUpload,
+ startUploadedTableImport: mockStartUploadedTableImport,
+ toV2TableImport: mockToV2TableImport,
+}))
+
+vi.mock('@/lib/uploads/upload-session/service', () => ({
+ completeUploadSession: mockCompleteUploadSession,
+}))
+
+import { POST } from '@/app/api/v2/tables/imports/[importId]/complete/route'
+
+const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8'
+const RATE_LIMIT = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2026-08-03T22:00:00.000Z'),
+}
+const UPLOAD = {
+ id: 'import-1',
+ workspaceId: WORKSPACE_ID,
+ userId: 'user-1',
+}
+
+function request() {
+ return POST(
+ new NextRequest(
+ `http://localhost:3000/api/v2/tables/imports/import-1/complete?workspaceId=${WORKSPACE_ID}`,
+ {
+ method: 'POST',
+ headers: {
+ 'upload-token': 'signed-upload-token',
+ },
+ }
+ ),
+ { params: Promise.resolve({ importId: 'import-1' }) }
+ )
+}
+
+describe('POST /api/v2/tables/imports/[importId]/complete', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT)
+ mockResolveWorkspaceScope.mockResolvedValue(null)
+ mockGetOwnedTableImportUpload.mockReturnValue(UPLOAD)
+ })
+
+ it('returns the existing table job when completion is retried', async () => {
+ const existing = { id: 'import-1', tableId: 'table-1', status: 'ready' }
+ const responseBody = { id: 'import-1', tableId: 'table-1', status: 'completed' }
+ mockFindOwnedTableImport.mockResolvedValue(existing)
+ mockToV2TableImport.mockReturnValue(responseBody)
+
+ const response = await request()
+
+ expect(response.status).toBe(200)
+ expect(await response.json()).toEqual({ data: responseBody })
+ expect(mockGetOwnedTableImportUpload).toHaveBeenCalledWith({
+ importId: 'import-1',
+ workspaceId: WORKSPACE_ID,
+ userId: 'user-1',
+ uploadToken: 'signed-upload-token',
+ })
+ expect(mockFindOwnedTableImport).toHaveBeenCalledWith({
+ importId: 'import-1',
+ workspaceId: WORKSPACE_ID,
+ userId: 'user-1',
+ })
+ expect(mockCompleteUploadSession).not.toHaveBeenCalled()
+ expect(mockStartUploadedTableImport).not.toHaveBeenCalled()
+ })
+
+ it('completes by upload id and starts the import job', async () => {
+ const started = { id: 'import-1', tableId: 'table-1', status: 'running' }
+ const responseBody = { id: 'import-1', tableId: 'table-1', status: 'processing' }
+ mockFindOwnedTableImport.mockResolvedValue(null)
+ mockCompleteUploadSession.mockResolvedValue({
+ session: UPLOAD,
+ value: null,
+ alreadyCompleted: false,
+ })
+ mockStartUploadedTableImport.mockResolvedValue(started)
+ mockToV2TableImport.mockReturnValue(responseBody)
+
+ const response = await request()
+
+ expect(response.status).toBe(200)
+ expect(mockCompleteUploadSession).toHaveBeenCalledWith({
+ session: UPLOAD,
+ finalize: expect.any(Function),
+ })
+ expect(mockStartUploadedTableImport).toHaveBeenCalledWith(UPLOAD)
+ expect(await response.json()).toEqual({ data: responseBody })
+ })
+})
diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts
new file mode 100644
index 00000000000..b001a3a053d
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts
@@ -0,0 +1,74 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { v2CompleteTableImportContract } from '@/lib/api/contracts/v2/tables'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ findOwnedTableImport,
+ getOwnedTableImportUpload,
+ startUploadedTableImport,
+ toV2TableImport,
+} from '@/lib/table/orchestration/import-resource'
+import { completeUploadSession } from '@/lib/uploads/upload-session/service'
+import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CaughtOrchestrationError,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+import { v2TableLockError } from '@/app/api/v2/tables/utils'
+
+const logger = createLogger('V2CompleteTableImportAPI')
+
+interface TableImportRouteParams {
+ params: Promise<{ importId: string }>
+}
+
+export const POST = withRouteHandler(
+ async (request: NextRequest, context: TableImportRouteParams) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-import')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(v2CompleteTableImportContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+ const { workspaceId } = parsed.data.query
+ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+ const upload = await getOwnedTableImportUpload({
+ importId: parsed.data.params.importId,
+ workspaceId,
+ userId,
+ uploadToken: parsed.data.headers['upload-token'],
+ })
+ const existing = await findOwnedTableImport({
+ importId: upload.id,
+ workspaceId,
+ userId: upload.userId,
+ })
+ if (existing) return v2Data(toV2TableImport(existing), { rateLimit })
+ const completed = await completeUploadSession({
+ session: upload,
+ finalize: async () => ({ value: null }),
+ })
+ const started = await startUploadedTableImport(completed.session)
+ return v2Data(await toV2TableImport(started), { rateLimit })
+ } catch (error) {
+ const lockError = v2TableLockError(error)
+ if (lockError) return lockError
+ const classified = v2CaughtOrchestrationError(error)
+ if (classified) return classified
+ logger.error('Failed to complete table import upload', { error: getErrorMessage(error) })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts
new file mode 100644
index 00000000000..6481d8ba478
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts
@@ -0,0 +1,60 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { v2CreateTableImportPartUrlsContract } from '@/lib/api/contracts/v2/tables'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getOwnedTableImportUpload } from '@/lib/table/orchestration/import-resource'
+import { createUploadPartUrls } from '@/lib/uploads/upload-session/service'
+import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CaughtOrchestrationError,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2TableImportPartsAPI')
+
+interface TableImportRouteParams {
+ params: Promise<{ importId: string }>
+}
+
+export const POST = withRouteHandler(
+ async (request: NextRequest, context: TableImportRouteParams) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-import')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(v2CreateTableImportPartUrlsContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+ const { workspaceId } = parsed.data.query
+ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+ const session = await getOwnedTableImportUpload({
+ importId: parsed.data.params.importId,
+ workspaceId,
+ userId,
+ uploadToken: parsed.data.headers['upload-token'],
+ })
+ const parts = await createUploadPartUrls({
+ session,
+ partNumbers: parsed.data.body.partNumbers,
+ localOrigin: request.nextUrl.origin,
+ })
+ return v2Data({ parts }, { rateLimit })
+ } catch (error) {
+ const classified = v2CaughtOrchestrationError(error)
+ if (classified) return classified
+ logger.error('Failed to create table import part URLs', { error: getErrorMessage(error) })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts
new file mode 100644
index 00000000000..22005ef907e
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts
@@ -0,0 +1,99 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import {
+ v2CancelTableImportContract,
+ v2GetTableImportContract,
+} from '@/lib/api/contracts/v2/tables'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ abortTableImportUpload,
+ cancelTableImportResource,
+ getOwnedTableImport,
+ toV2TableImport,
+} from '@/lib/table/orchestration/import-resource'
+import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CaughtOrchestrationError,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2TableImportAPI')
+
+interface TableImportRouteParams {
+ params: Promise<{ importId: string }>
+}
+
+export const GET = withRouteHandler(
+ async (request: NextRequest, context: TableImportRouteParams) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-import')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(v2GetTableImportContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+ const scopeError = await resolveWorkspaceScope(rateLimit, parsed.data.query.workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+ const record = await getOwnedTableImport({
+ importId: parsed.data.params.importId,
+ workspaceId: parsed.data.query.workspaceId,
+ userId,
+ })
+ return v2Data(await toV2TableImport(record), { rateLimit })
+ } catch (error) {
+ const classified = v2CaughtOrchestrationError(error)
+ if (classified) return classified
+ logger.error('Failed to read table import', { error: getErrorMessage(error) })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
+
+export const DELETE = withRouteHandler(
+ async (request: NextRequest, context: TableImportRouteParams) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-import')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(v2CancelTableImportContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+ const scopeError = await resolveWorkspaceScope(rateLimit, parsed.data.query.workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+ const uploadToken = parsed.data.headers['upload-token']
+ const record = uploadToken
+ ? await abortTableImportUpload({
+ importId: parsed.data.params.importId,
+ workspaceId: parsed.data.query.workspaceId,
+ userId,
+ uploadToken,
+ })
+ : await cancelTableImportResource(
+ await getOwnedTableImport({
+ importId: parsed.data.params.importId,
+ workspaceId: parsed.data.query.workspaceId,
+ userId,
+ })
+ )
+ return v2Data(toV2TableImport(record), { rateLimit })
+ } catch (error) {
+ const classified = v2CaughtOrchestrationError(error)
+ if (classified) return classified
+ logger.error('Failed to cancel table import', { error: getErrorMessage(error) })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/tables/imports/route.test.ts b/apps/sim/app/api/v2/tables/imports/route.test.ts
new file mode 100644
index 00000000000..f5c6a5f7541
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/imports/route.test.ts
@@ -0,0 +1,147 @@
+/**
+ * @vitest-environment node
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockResolveWorkspaceScope,
+ mockCreateTableImportResource,
+ mockToV2CreateTableImport,
+ mockLoadActiveFolderPathIndex,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceScope: vi.fn(),
+ mockCreateTableImportResource: vi.fn(),
+ mockToV2CreateTableImport: vi.fn(),
+ mockLoadActiveFolderPathIndex: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceScope: mockResolveWorkspaceScope,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+vi.mock('@/app/api/v2/tables/utils', () => ({
+ v2TableLockError: vi.fn().mockReturnValue(null),
+}))
+
+vi.mock('@/lib/table/orchestration/import-resource', () => ({
+ createTableImportResource: mockCreateTableImportResource,
+ toV2CreateTableImport: mockToV2CreateTableImport,
+}))
+
+vi.mock('@/lib/folders/queries', () => ({
+ loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex,
+}))
+
+import { POST } from '@/app/api/v2/tables/imports/route'
+
+const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8'
+const RATE_LIMIT = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2026-08-03T22:00:00.000Z'),
+}
+
+describe('POST /api/v2/tables/imports', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT)
+ mockResolveWorkspaceScope.mockResolvedValue(null)
+ mockLoadActiveFolderPathIndex.mockResolvedValue({
+ rowById: new Map(),
+ pathById: new Map(),
+ idByPath: new Map(),
+ })
+ })
+
+ it.each([
+ [
+ 'upload',
+ { type: 'upload', name: 'data.csv', contentType: 'text/csv', size: 128 },
+ {
+ session: { id: 'import-1', source: { type: 'upload' } },
+ uploadToken: 'signed-token',
+ transfer: { method: 'put', url: 'https://storage.example/upload', headers: {} },
+ },
+ ],
+ [
+ 'workspace file',
+ { type: 'workspace_file', fileId: 'file-1' },
+ {
+ session: { id: 'import-1', source: { type: 'workspace_file', fileId: 'file-1' } },
+ uploadToken: null,
+ transfer: null,
+ },
+ ],
+ ])('returns the create envelope for a %s source', async (_label, source, responseData) => {
+ const requestBody = {
+ workspaceId: WORKSPACE_ID,
+ source,
+ target: { type: 'new', name: 'imported_data' },
+ }
+ const created = { record: { id: 'import-1' }, upload: null }
+ mockCreateTableImportResource.mockResolvedValue(created)
+ mockToV2CreateTableImport.mockReturnValue(responseData)
+
+ const response = await POST(
+ new NextRequest('http://localhost:3000/api/v2/tables/imports', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(requestBody),
+ })
+ )
+
+ expect(response.status).toBe(201)
+ expect(mockCreateTableImportResource).toHaveBeenCalledWith(
+ requestBody,
+ 'user-1',
+ 'http://localhost:3000',
+ null
+ )
+ expect(mockToV2CreateTableImport).toHaveBeenCalledWith(created)
+ expect(await response.json()).toEqual({ data: responseData })
+ })
+
+ it('accepts native JSON mapping and createColumns values', async () => {
+ const requestBody = {
+ workspaceId: WORKSPACE_ID,
+ source: { type: 'upload', name: 'data.csv', contentType: 'text/csv', size: 128 },
+ target: { type: 'existing', tableId: 'table-1', mode: 'append' },
+ mapping: { email: 'email_address', notes: null },
+ createColumns: ['phone'],
+ }
+ const created = { record: { id: 'import-1' }, upload: null }
+ const responseData = {
+ session: { id: 'import-1', source: { type: 'upload' } },
+ uploadToken: 'signed-token',
+ transfer: { method: 'put', url: 'https://storage.example/upload', headers: {} },
+ }
+ mockCreateTableImportResource.mockResolvedValue(created)
+ mockToV2CreateTableImport.mockReturnValue(responseData)
+
+ const response = await POST(
+ new NextRequest('http://localhost:3000/api/v2/tables/imports', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(requestBody),
+ })
+ )
+
+ expect(response.status).toBe(201)
+ expect(mockCreateTableImportResource).toHaveBeenCalledWith(
+ requestBody,
+ 'user-1',
+ 'http://localhost:3000'
+ )
+ })
+})
diff --git a/apps/sim/app/api/v2/tables/imports/route.ts b/apps/sim/app/api/v2/tables/imports/route.ts
new file mode 100644
index 00000000000..43ce013fa3b
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/imports/route.ts
@@ -0,0 +1,70 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { v2CreateTableImportContract } from '@/lib/api/contracts/v2/tables'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ createTableImportResource,
+ toV2CreateTableImport,
+} from '@/lib/table/orchestration/import-resource'
+import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware'
+import { resolveFolderPathIdentity } from '@/app/api/v2/lib/folders'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CaughtOrchestrationError,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+import { v2TableLockError } from '@/app/api/v2/tables/utils'
+
+const logger = createLogger('V2TableImportsAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ try {
+ const rateLimit = await checkRateLimit(request, 'table-import')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(
+ v2CreateTableImportContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+ const scopeError = await resolveWorkspaceScope(rateLimit, parsed.data.body.workspaceId)
+ if (scopeError) return v2WorkspaceAccessError(scopeError)
+ let created: Awaited>
+ if (parsed.data.body.target.type === 'new') {
+ const resolution = await resolveFolderPathIdentity({
+ workspaceId: parsed.data.body.workspaceId,
+ resourceType: 'table',
+ path: parsed.data.body.target.folderPath ?? '/',
+ })
+ if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found')
+ created = await createTableImportResource(
+ parsed.data.body,
+ userId,
+ request.nextUrl.origin,
+ resolution.folderId
+ )
+ } else {
+ created = await createTableImportResource(parsed.data.body, userId, request.nextUrl.origin)
+ }
+ return v2Data(toV2CreateTableImport(created), { rateLimit, status: 201 })
+ } catch (error) {
+ const lockError = v2TableLockError(error)
+ if (lockError) return lockError
+ const classified = v2CaughtOrchestrationError(error)
+ if (classified) return classified
+ logger.error('Failed to create table import', { error: getErrorMessage(error) })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/tables/route.test.ts b/apps/sim/app/api/v2/tables/route.test.ts
index 2880355c8df..b5af7c73822 100644
--- a/apps/sim/app/api/v2/tables/route.test.ts
+++ b/apps/sim/app/api/v2/tables/route.test.ts
@@ -1,45 +1,92 @@
/**
* @vitest-environment node
*
- * Public v2 tables list: auth/scope gating, typed summary output, private cache header.
+ * Public v2 tables list: auth/scope gating, rollout gate ordering, typed
+ * summary output in the `{ data, nextCursor }` envelope, private cache header.
*/
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TableDefinition } from '@/lib/table/types'
-const { mockListTables, mockCheckRateLimit, mockValidateWorkspaceAccess, mockGate } = vi.hoisted(
- () => ({
- mockListTables: vi.fn(),
- mockCheckRateLimit: vi.fn(),
- mockValidateWorkspaceAccess: vi.fn(),
- mockGate: vi.fn(),
- })
-)
+const {
+ mockQueryTables,
+ mockCheckRateLimit,
+ mockResolveWorkspaceAccess,
+ mockIsFeatureEnabled,
+ mockGetWorkspaceOrganizationId,
+ mockLoadActiveFolderPathIndex,
+ mockResolveFolderPathIdentity,
+ mockCreateTable,
+ mockGetWorkspaceTableLimits,
+} = vi.hoisted(() => ({
+ mockQueryTables: vi.fn(),
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceAccess: vi.fn(),
+ mockIsFeatureEnabled: vi.fn(),
+ mockGetWorkspaceOrganizationId: vi.fn(),
+ mockLoadActiveFolderPathIndex: vi.fn(),
+ mockResolveFolderPathIdentity: vi.fn(),
+ mockCreateTable: vi.fn(),
+ mockGetWorkspaceTableLimits: vi.fn(),
+}))
-vi.mock('@/app/api/v1/middleware', async () => {
- const { NextResponse } = await import('next/server')
- return {
- checkRateLimit: mockCheckRateLimit,
- validateWorkspaceAccess: mockValidateWorkspaceAccess,
- createRateLimitResponse: (r: { error?: string }) =>
- NextResponse.json(
- { error: r.error ?? 'Rate limit exceeded' },
- { status: r.error ? 401 : 429 }
- ),
- }
-})
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceAccess: mockResolveWorkspaceAccess,
+}))
vi.mock('@/lib/table', async () => {
const actual = await import('@/lib/table/column-keys')
- return { ...actual, listTables: mockListTables }
+ return {
+ ...actual,
+ queryTables: mockQueryTables,
+ createTable: mockCreateTable,
+ getWorkspaceTableLimits: mockGetWorkspaceTableLimits,
+ }
})
vi.mock('@/app/api/table/utils', () => ({
normalizeColumn: (col: Record) => col,
- tablesV2GateError: mockGate,
+ rootErrorMessage: (error: unknown) => String(error),
+ rowWriteErrorResponse: () => null,
+}))
+
+vi.mock('@/lib/core/config/feature-flags', () => ({
+ isFeatureEnabled: mockIsFeatureEnabled,
+}))
+
+vi.mock('@/lib/workspaces/utils', () => ({
+ getWorkspaceOrganizationId: mockGetWorkspaceOrganizationId,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
}))
-import { GET } from '@/app/api/v2/tables/route'
+vi.mock('@/lib/folders/queries', () => ({
+ loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex,
+}))
+
+vi.mock('@/app/api/v2/lib/folders', () => ({
+ folderPathForId: (_index: unknown, folderId: string | null | undefined) =>
+ folderId ? '/Reports' : '/',
+ resolveFolderPathId: (
+ index: { idByPath: Map },
+ path: string
+ ): string | null | undefined => (path === '/' ? null : index.idByPath.get(path)),
+ resolveFolderPathIdentity: mockResolveFolderPathIdentity,
+}))
+
+import { GET, POST } from '@/app/api/v2/tables/route'
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+}
function buildTable(): TableDefinition {
return {
@@ -63,69 +110,183 @@ function callList(query: string) {
return GET(req)
}
+function callCreate(body: Record) {
+ return POST(
+ new NextRequest('http://localhost:3000/api/v2/tables', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ )
+}
+
describe('GET /api/v2/tables', () => {
beforeEach(() => {
vi.clearAllMocks()
- mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1', keyType: 'workspace' })
- mockValidateWorkspaceAccess.mockResolvedValue(null)
- mockListTables.mockResolvedValue([buildTable()])
- mockGate.mockResolvedValue(null)
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockQueryTables.mockResolvedValue({ tables: [buildTable()], nextKeys: null })
+ mockIsFeatureEnabled.mockResolvedValue(true)
+ mockGetWorkspaceOrganizationId.mockResolvedValue('org-1')
+ mockLoadActiveFolderPathIndex.mockResolvedValue({
+ rowById: new Map(),
+ pathById: new Map(),
+ idByPath: new Map(),
+ })
})
- it('returns 404 when the tables-v2-api flag is off', async () => {
- const { NextResponse } = await import('next/server')
- mockGate.mockResolvedValue(NextResponse.json({ error: 'Not found' }, { status: 404 }))
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
const res = await callList('workspaceId=workspace-1')
+
expect(res.status).toBe(404)
- expect(mockListTables).not.toHaveBeenCalled()
+ expect((await res.json()).error.code).toBe('NOT_FOUND')
+ expect(mockQueryTables).not.toHaveBeenCalled()
})
- it('runs the flag gate only after the access check, so it cannot leak a cohort oracle', async () => {
- const { NextResponse } = await import('next/server')
- mockValidateWorkspaceAccess.mockResolvedValue(
- NextResponse.json({ error: 'Access denied' }, { status: 403 })
- )
+ it('400s when workspaceId is missing', async () => {
+ const res = await callList('')
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(mockQueryTables).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue({
+ status: 403,
+ code: 'FORBIDDEN',
+ message: 'Access denied',
+ })
const res = await callList('workspaceId=workspace-1')
expect(res.status).toBe(403)
- expect(mockGate).not.toHaveBeenCalled()
+ expect((await res.json()).error).toMatchObject({ code: 'FORBIDDEN', message: 'Access denied' })
+ expect(mockQueryTables).not.toHaveBeenCalled()
})
- it('returns a typed table summary with a private cache header', async () => {
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue({
+ allowed: false,
+ limit: 100,
+ remaining: 0,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+ retryAfterMs: 1000,
+ })
const res = await callList('workspaceId=workspace-1')
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+ it('400s on a sort field outside the enum instead of letting it reach the query', async () => {
+ const res = await callList(`workspaceId=workspace-1&sortBy=name);--`)
+
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ })
+
+ it('400s on a sort direction outside the enum', async () => {
+ const res = await callList(`workspaceId=workspace-1&sortOrder=sideways`)
+
+ expect(res.status).toBe(400)
+ })
+
+ it('400s on an empty search rather than treating it as unsearched', async () => {
+ const res = await callList(`workspaceId=workspace-1&search=`)
+
+ expect(res.status).toBe(400)
+ })
+
+ it('forwards search and sort into the query and still terminates pagination', async () => {
+ const res = await callList(`workspaceId=workspace-1&search=report&sortBy=name&sortOrder=asc`)
+
expect(res.status).toBe(200)
- expect(res.headers.get('Cache-Control')).toBe('private, no-store')
+ expect((await res.json()).nextCursor).toBeNull()
+ })
+
+ it('treats folderPath=/ as root-only while omission lists every folder', async () => {
+ await callList('workspaceId=workspace-1&folderPath=%2F')
+
+ expect(mockQueryTables).toHaveBeenCalledWith(
+ 'workspace-1',
+ expect.objectContaining({ folderId: null })
+ )
+ })
+
+ it('passes limit and the decoded cursor through to the query', async () => {
+ mockQueryTables.mockResolvedValue({ tables: [buildTable()], nextKeys: null })
+
+ await callList('workspaceId=workspace-1&limit=25&sortBy=name&sortOrder=desc')
+
+ // The slice must happen in the query, not after a full-workspace read.
+ expect(mockQueryTables).toHaveBeenCalledWith(
+ 'workspace-1',
+ expect.objectContaining({ limit: 25, sortBy: 'name', sortOrder: 'desc' })
+ )
+ })
+
+ it('returns a nextCursor when the query reports another page', async () => {
+ mockQueryTables.mockResolvedValue({ tables: [buildTable()], nextKeys: ['Alpha', 'tbl_1'] })
+
+ const res = await callList('workspaceId=workspace-1&limit=1')
const body = await res.json()
- expect(body.data.totalCount).toBe(1)
- expect(body.data.tables[0]).toMatchObject({
- id: 'tbl_1',
- name: 'People',
- description: 'A table',
- rowCount: 5,
- maxRows: 100,
- createdAt: '2024-01-01T00:00:00.000Z',
- })
- expect(body.data.tables[0].schema.columns[0].name).toBe('name')
+
+ expect(res.status).toBe(200)
+ expect(body.nextCursor).toEqual(expect.any(String))
})
- it('400s when workspaceId is missing', async () => {
- const res = await callList('')
+ it('rejects a cursor that does not match the requested sort', async () => {
+ const first = await callList('workspaceId=workspace-1&sortBy=name')
+ // Encoded under sortBy=name, replayed under sortBy=createdAt.
+ mockQueryTables.mockResolvedValue({ tables: [buildTable()], nextKeys: ['Alpha', 'tbl_1'] })
+ const paged = await callList('workspaceId=workspace-1&sortBy=name&limit=1')
+ const cursor = (await paged.json()).nextCursor
+
+ const res = await callList(
+ `?workspaceId=workspace-1&sortBy=createdAt&cursor=${encodeURIComponent(cursor)}`
+ )
+
expect(res.status).toBe(400)
- expect(mockListTables).not.toHaveBeenCalled()
+ expect(first.status).toBe(200)
})
+})
- it('surfaces an access-denied response from the middleware', async () => {
- const { NextResponse } = await import('next/server')
- mockValidateWorkspaceAccess.mockResolvedValue(
- NextResponse.json({ error: 'Access denied' }, { status: 403 })
- )
- const res = await callList('workspaceId=workspace-1')
- expect(res.status).toBe(403)
- expect(mockListTables).not.toHaveBeenCalled()
+describe('POST /api/v2/tables', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockGetWorkspaceTableLimits.mockResolvedValue({ maxTables: 100 })
+ mockResolveFolderPathIdentity.mockResolvedValue({
+ found: true,
+ folderId: 'folder-1',
+ index: {
+ rowById: new Map(),
+ pathById: new Map([['folder-1', '/Reports']]),
+ idByPath: new Map([['/Reports', 'folder-1']]),
+ },
+ })
+ mockCreateTable.mockResolvedValue({ ...buildTable(), folderId: 'folder-1' })
})
- it('returns the rate-limit response when denied', async () => {
- mockCheckRateLimit.mockResolvedValue({ allowed: false })
- const res = await callList('workspaceId=workspace-1')
- expect(res.status).toBe(429)
+ it('resolves a slashless folder path before creating the table outside the folder lock', async () => {
+ const res = await callCreate({
+ workspaceId: 'workspace-1',
+ name: 'People',
+ folderPath: 'Reports',
+ schema: { columns: [{ name: 'email', type: 'string' }] },
+ })
+
+ expect(res.status).toBe(201)
+ expect(mockResolveFolderPathIdentity).toHaveBeenCalledWith({
+ workspaceId: 'workspace-1',
+ resourceType: 'table',
+ path: '/Reports',
+ })
+ expect(mockCreateTable).toHaveBeenCalledWith(
+ expect.objectContaining({ folderId: 'folder-1' }),
+ expect.any(String)
+ )
+ expect((await res.json()).data.table.folderPath).toBe('/Reports')
})
})
diff --git a/apps/sim/app/api/v2/tables/route.ts b/apps/sim/app/api/v2/tables/route.ts
index be0c59c32b6..c3d319bbafd 100644
--- a/apps/sim/app/api/v2/tables/route.ts
+++ b/apps/sim/app/api/v2/tables/route.ts
@@ -1,78 +1,182 @@
+import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
-import { type NextRequest, NextResponse } from 'next/server'
-import { v2ListTablesContract } from '@/lib/api/contracts/v2/tables'
-import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { v2CreateTableContract, v2ListTablesContract } from '@/lib/api/contracts/v2/tables'
+import { isZodError, parseRequest } from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { listTables, type TableSchema } from '@/lib/table'
-import { normalizeColumn, tablesV2GateError } from '@/app/api/table/utils'
+import { loadActiveFolderPathIndex } from '@/lib/folders/queries'
+import { createTable, getWorkspaceTableLimits, queryTables, type TableSchema } from '@/lib/table'
+import { normalizeColumn } from '@/app/api/table/utils'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
import {
- checkRateLimit,
- createRateLimitResponse,
- validateWorkspaceAccess,
-} from '@/app/api/v1/middleware'
+ folderPathForId,
+ resolveFolderPathId,
+ resolveFolderPathIdentity,
+} from '@/app/api/v2/lib/folders'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ cursorSortKey,
+ decodeSortedCursor,
+ encodeSortedCursor,
+ v2CaughtOrchestrationError,
+ v2CursorList,
+ v2CursorSortError,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+import { toApiTable } from '@/app/api/v2/tables/utils'
const logger = createLogger('V2TablesAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
-/** Filters/ids can appear in query strings; keep list responses out of shared caches. */
-const PRIVATE_NO_STORE = { 'Cache-Control': 'private, no-store' } as const
-
-/** GET /api/v2/tables — list all tables in a workspace. */
+/** GET /api/v2/tables — List all tables in a workspace. */
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
- const rateLimit = await checkRateLimit(request, 'v2-tables')
- if (!rateLimit.allowed) return createRateLimitResponse(rateLimit)
+ const rateLimit = await checkRateLimit(request, 'tables')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
const userId = rateLimit.userId!
- const parsed = await parseRequest(v2ListTablesContract, request, {})
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2ListTablesContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
if (!parsed.success) return parsed.response
- const { workspaceId } = parsed.data.query
+ const { workspaceId, folderPath, search, sortBy, sortOrder, limit, cursor } = parsed.data.query
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
+ if (access) return v2WorkspaceAccessError(access)
- const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId)
- if (accessError) return accessError
+ const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'table')
+ const folderId =
+ folderPath === undefined ? undefined : resolveFolderPathId(folderIndex, folderPath)
+ if (folderPath !== undefined && folderId === undefined) {
+ return v2Error('NOT_FOUND', 'Folder not found')
+ }
- // After authz: the gate reads the workspace's org off the primary DB, and its
- // 404 would otherwise distinguish "not in the rollout cohort" from "no access".
- const gateError = await tablesV2GateError(userId, workspaceId)
- if (gateError) return gateError
+ const sort = cursorSortKey(sortBy, sortOrder)
+ const decoded = decodeSortedCursor(cursor, sort)
+ if (decoded.status === 'invalid') return v2CursorSortError()
- const tables = await listTables(workspaceId)
+ const { tables, nextKeys } = await queryTables(workspaceId, {
+ folderId,
+ search,
+ sortBy,
+ sortOrder,
+ limit,
+ after: decoded.status === 'ok' ? decoded.keys : undefined,
+ })
+
+ const items = tables.map((table) =>
+ toApiTable(table, folderPathForId(folderIndex, table.folderId))
+ )
+ const nextCursor = nextKeys ? encodeSortedCursor(sort, nextKeys) : null
+
+ return v2CursorList(items, nextCursor, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Error listing tables`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/** POST /api/v2/tables — Create a new table. */
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'tables')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
- return NextResponse.json(
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2CreateTableContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ const params = parsed.data.body
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const planLimits = await getWorkspaceTableLimits(params.workspaceId)
+
+ const normalizedSchema: TableSchema = {
+ columns: params.schema.columns.map(normalizeColumn),
+ }
+
+ const resolution = await resolveFolderPathIdentity({
+ workspaceId: params.workspaceId,
+ resourceType: 'table',
+ path: params.folderPath ?? '/',
+ })
+ if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found')
+
+ const table = await createTable(
{
- success: true,
- data: {
- tables: tables.map((t) => {
- const schemaData = t.schema as TableSchema
- return {
- id: t.id,
- name: t.name,
- description: t.description,
- schema: { columns: schemaData.columns.map(normalizeColumn) },
- rowCount: t.rowCount,
- maxRows: t.maxRows,
- createdAt:
- t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt),
- updatedAt:
- t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt),
- }
- }),
- totalCount: tables.length,
- },
+ name: params.name,
+ description: params.description,
+ schema: normalizedSchema,
+ workspaceId: params.workspaceId,
+ userId,
+ maxTables: planLimits.maxTables,
+ folderId: resolution.folderId,
},
- { headers: PRIVATE_NO_STORE }
+ requestId
+ )
+
+ recordAudit({
+ workspaceId: params.workspaceId,
+ actorId: userId,
+ action: AuditAction.TABLE_CREATED,
+ resourceType: AuditResourceType.TABLE,
+ resourceId: table.id,
+ resourceName: table.name,
+ description: `Created table "${table.name}" via API`,
+ metadata: { columnCount: params.schema.columns.length },
+ request,
+ })
+
+ return v2Data(
+ { table: toApiTable(table, folderPathForId(resolution.index, table.folderId)) },
+ { rateLimit, status: 201 }
)
} catch (error) {
- const validationResponse = validationErrorResponseFromError(error)
- if (validationResponse) return validationResponse
+ if (isZodError(error)) return v2ValidationError(error)
+
+ const classified = v2CaughtOrchestrationError(error)
+ if (classified) return classified
- logger.error(`[${requestId}] Error listing tables:`, error)
- return NextResponse.json({ error: 'Failed to list tables' }, { status: 500 })
+ logger.error(`[${requestId}] Error creating table`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
}
})
diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts
new file mode 100644
index 00000000000..d3cd6dfe6e5
--- /dev/null
+++ b/apps/sim/app/api/v2/tables/utils.ts
@@ -0,0 +1,260 @@
+import type { NextResponse } from 'next/server'
+import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types'
+import type { MultipartError } from '@/lib/core/utils/multipart'
+import type { RowData, TableDefinition, TablePredicate, TableSchema } from '@/lib/table'
+import { getColumnId } from '@/lib/table/column-keys'
+import { TableLockedError } from '@/lib/table/mutation-locks'
+import { predicateToFilter } from '@/lib/table/query-builder/converters'
+import {
+ validatePredicateShape,
+ validateStoragePredicate,
+} from '@/lib/table/query-builder/validate'
+import { predicateToStorage } from '@/lib/table/select-values'
+import type { Filter, TableLockKind } from '@/lib/table/types'
+import type { TableView } from '@/lib/table/views/service'
+import {
+ CSV_IMPORT_PROXY_BODY_CAP_BYTES,
+ normalizeColumn,
+ rootErrorMessage,
+ rowWriteErrorResponse,
+} from '@/app/api/table/utils'
+import { v2Error, v2ErrorForOrchestration } from '@/app/api/v2/lib/response'
+
+/**
+ * Shared serialization + error helpers for the v2 tables surface. Every v2
+ * table/row/column route renders its payloads and access failures through these
+ * so the public shape, timestamp format, and error envelope stay identical
+ * across the surface. These reuse the v1 platform services and classifiers —
+ * only the HTTP envelope is upgraded.
+ */
+
+/** ISO-serializes a `Date | string` timestamp from the table service layer. */
+function toIso(value: Date | string): string {
+ return value instanceof Date ? value.toISOString() : String(value)
+}
+
+/**
+ * Resolves a public v2 bulk-op predicate to the storage-id-keyed legacy `Filter`
+ * the row runners consume. The public wire is column-NAME-keyed: shape-check
+ * first (keying-agnostic), translate names → storage ids (including select
+ * operand names → option ids), then validate the RESULT against storage keys —
+ * on a destructive path an unresolved field must 400, not silently match
+ * nothing.
+ */
+export function v2BulkPredicateToFilter(predicate: TablePredicate, schema: TableSchema): Filter {
+ validatePredicateShape(predicate)
+ const translated = predicateToStorage(predicate, schema)
+ validateStoragePredicate(translated, schema.columns)
+ return predicateToFilter(translated)
+}
+
+/**
+ * Normalized public table shape — the same subset of fields the v1 surface
+ * exposes, with timestamps serialized to ISO strings. Shared by every v2 table
+ * endpoint so the table payload is identical across the surface.
+ */
+export function toApiTable(table: TableDefinition, folderPath: string) {
+ return {
+ id: table.id,
+ name: table.name,
+ description: table.description,
+ schema: {
+ columns: (table.schema as TableSchema).columns.map(normalizeColumn),
+ },
+ rowCount: table.rowCount,
+ maxRows: table.maxRows,
+ folderPath,
+ locks: table.locks,
+ // `jobStatus` is the presence signal — the service leaves the whole group
+ // null when the table is idle. Without this an async import could be
+ // started and cancelled but never observed to completion or failure.
+ job: table.jobStatus
+ ? {
+ id: table.jobId ?? null,
+ type: table.jobType ?? null,
+ status: table.jobStatus,
+ rowsProcessed: table.jobRowsProcessed ?? 0,
+ error: table.jobError ?? null,
+ }
+ : null,
+ createdAt: toIso(table.createdAt),
+ updatedAt: toIso(table.updatedAt),
+ }
+}
+
+/**
+ * Normalized public view shape. Identical to the stored view except that the
+ * timestamps are ISO strings, matching every other v2 payload.
+ */
+export function toApiView(view: TableView) {
+ return {
+ id: view.id,
+ tableId: view.tableId,
+ name: view.name,
+ config: view.config,
+ isDefault: view.isDefault,
+ createdBy: view.createdBy,
+ createdAt: toIso(view.createdAt),
+ updatedAt: toIso(view.updatedAt),
+ }
+}
+
+/**
+ * Maps a stored column id (the JSONB key that `findRowMatches` reports) back to
+ * its display name, so cell references on the public wire are name-keyed like
+ * row `data`. Falls back to the id for a column that no longer exists.
+ */
+export function columnNameById(schema: TableSchema): (columnId: string) => string {
+ const nameById = new Map(schema.columns.map((column) => [getColumnId(column), column.name]))
+ return (columnId) => nameById.get(columnId) ?? columnId
+}
+
+/**
+ * Row fields the public API exposes. `data` is stored id-keyed; {@link toApiRow}
+ * translates it to column names.
+ */
+interface ApiRowInput {
+ id: string
+ data: RowData
+ createdAt: Date | string
+ updatedAt: Date | string
+}
+
+/**
+ * Normalized public row shape: `{ id, data, createdAt, updatedAt }`, no storage
+ * internals (`position`/`orderKey`/`executions`). Callers pass a
+ * `namedRowMapper(schema.columns)` so `data` is keyed by column NAME and select
+ * cells surface their option NAME rather than the stored option id.
+ */
+export function toApiRow(row: ApiRowInput, toNamedRow: (data: RowData) => RowData) {
+ return {
+ id: row.id,
+ data: toNamedRow(row.data),
+ createdAt: toIso(row.createdAt),
+ updatedAt: toIso(row.updatedAt),
+ }
+}
+
+/**
+ * Maps a {@link MultipartError} from the streaming CSV reader to the v2
+ * envelope. Mirrors v1's {@link multipartErrorResponse} — same classification,
+ * different envelope.
+ */
+export function v2MultipartError(error: MultipartError): NextResponse {
+ if (error.code === 'FILE_TOO_LARGE') {
+ return v2Error('PAYLOAD_TOO_LARGE', 'CSV import file exceeds maximum size')
+ }
+ return error.code === 'NO_FILE'
+ ? v2Error('BAD_REQUEST', 'CSV file is required')
+ : v2Error('BAD_REQUEST', `Invalid CSV upload: ${error.message}`)
+}
+
+/**
+ * 413 when a synchronous CSV upload would exceed the proxy's body cap; `null`
+ * otherwise. Next buffers the request body for the proxy and silently
+ * TRUNCATES it past the cap, so an unchecked oversize upload imports a partial
+ * file and reports success — the failure this exists to prevent.
+ */
+export function v2CsvBodyCapError(request: { headers: Headers }): NextResponse | null {
+ const contentLength = Number(request.headers.get('content-length') ?? 0)
+ if (contentLength <= CSV_IMPORT_PROXY_BODY_CAP_BYTES) return null
+ return v2Error(
+ 'PAYLOAD_TOO_LARGE',
+ 'File too large to import through the server. Upload it to workspace storage and use the async import instead.'
+ )
+}
+
+/**
+ * Renders a failed {@link checkAccess} result on a MUTATION path: a missing
+ * table stays 404, a missing permission stays 403. Read paths instead mask both
+ * as 404 inline so cross-workspace resource existence is never leaked.
+ */
+export function v2TableAccessError(result: { ok: false; status: 404 | 403 }): NextResponse {
+ return result.status === 404
+ ? v2Error('NOT_FOUND', 'Table not found')
+ : v2Error('FORBIDDEN', 'Access denied')
+}
+
+/**
+ * Maps a delete/write rejected by a table lock to the v2 `LOCKED` envelope,
+ * mirroring v1's {@link tableLockErrorResponse}. Returns `null` for anything
+ * else so the caller falls through to its own classification.
+ *
+ * `details.lock` names the flag that rejected the write. A table carries four
+ * independent locks, so "locked" on its own does not tell a caller which one to
+ * clear — every 423 on the surface reports it.
+ */
+export function v2TableLockError(
+ error: unknown,
+ /** Merged into `details` — e.g. which operations of a composite write landed. */
+ extraDetails?: Record
+): NextResponse | null {
+ if (error instanceof TableLockedError) {
+ return v2Error('LOCKED', error.message, { details: { lock: error.lock, ...extraDetails } })
+ }
+ return null
+}
+
+/** The failure half of any `lib/table/orchestration` result. */
+export interface OrchestrationOutcome {
+ errorCode?: OrchestrationErrorCode
+ error?: string
+ lock?: TableLockKind
+}
+
+/**
+ * Renders a `lib/table/orchestration` failure in the v2 envelope, naming the
+ * lock when one caused it.
+ *
+ * A lock rejection reaches a route two different ways — thrown and caught at
+ * the boundary ({@link v2TableLockError}), or returned as a classified
+ * `errorCode: 'locked'` outcome — and both must produce the same body. Plain
+ * {@link v2ErrorForOrchestration} cannot, because the `lock` kind lives on the
+ * outcome rather than the code, so every table route that renders an
+ * orchestration result goes through this instead.
+ */
+export function v2TableOrchestrationError(
+ outcome: OrchestrationOutcome,
+ fallback: string,
+ /** Merged into `details` — e.g. which operations of a composite write landed. */
+ extraDetails?: Record
+): NextResponse {
+ // `lock` is omitted rather than sent as null when the kind is unknown — a
+ // caller branching on `details.lock` should see absence, not a phantom value.
+ const details = {
+ ...(outcome.errorCode === 'locked' && outcome.lock ? { lock: outcome.lock } : {}),
+ ...extraDetails,
+ }
+ return v2ErrorForOrchestration(
+ outcome.errorCode,
+ outcome.error ?? fallback,
+ Object.keys(details).length > 0 ? details : undefined
+ )
+}
+
+/**
+ * Maps a known user-facing row-write failure (schema/size/unique/limit) to a v2
+ * `BAD_REQUEST`, reusing v1's {@link rowWriteErrorResponse} classifier as the
+ * single source of truth for which messages are safe to surface. Returns `null`
+ * for unrecognized errors so the caller logs and returns a generic 500.
+ */
+export function v2RowWriteError(error: unknown): NextResponse | null {
+ if (!rowWriteErrorResponse(error)) return null
+ return v2Error('BAD_REQUEST', rootErrorMessage(error))
+}
+
+/**
+ * Adapts a failed-row validation from the shared `validateRowData` /
+ * `validateBatchRows` helpers — which bake a v1-shaped `{ error, details }` 400
+ * response — into the canonical v2 error envelope while preserving the
+ * structured `details` (per-field / per-row). The validators expose the failure
+ * only as a rendered response, so the body is read back rather than
+ * re-implementing the size/schema/unique checks.
+ */
+export async function v2RowValidationError(response: NextResponse): Promise {
+ const body = (await response
+ .clone()
+ .json()
+ .catch(() => ({}))) as { error?: string; details?: unknown }
+ return v2Error('BAD_REQUEST', body.error ?? 'Invalid row data', { details: body.details })
+}
diff --git a/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.test.ts b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.test.ts
new file mode 100644
index 00000000000..f5cc527da7d
--- /dev/null
+++ b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.test.ts
@@ -0,0 +1,115 @@
+/**
+ * @vitest-environment node
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ MockLocalUploadBodyError,
+ mockExpectedUploadPartSize,
+ mockVerifyUploadSessionToken,
+ mockWriteLocalMultipartPart,
+} = vi.hoisted(() => {
+ class MockLocalUploadBodyError extends Error {}
+ return {
+ MockLocalUploadBodyError,
+ mockExpectedUploadPartSize: vi.fn(),
+ mockVerifyUploadSessionToken: vi.fn(),
+ mockWriteLocalMultipartPart: vi.fn(),
+ }
+})
+
+vi.mock('@/lib/uploads/upload-session/provider', () => ({
+ LocalUploadBodyError: MockLocalUploadBodyError,
+ writeLocalMultipartPart: mockWriteLocalMultipartPart,
+}))
+
+vi.mock('@/lib/uploads/upload-session/service', () => ({
+ expectedUploadPartSize: mockExpectedUploadPartSize,
+ verifyUploadSessionToken: mockVerifyUploadSessionToken,
+}))
+
+import { PUT } from '@/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route'
+
+const SESSION = {
+ id: 'upload-1',
+ storageProvider: 'local',
+ method: 'multipart',
+ status: 'uploading',
+ expiresAt: new Date('2999-01-01T00:00:00.000Z'),
+} as const
+
+describe('PUT /api/v2/uploads/[uploadId]/parts/[partNumber]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockVerifyUploadSessionToken.mockReturnValue(SESSION)
+ mockExpectedUploadPartSize.mockReturnValue(3)
+ mockWriteLocalMultipartPart.mockResolvedValue(undefined)
+ })
+
+ it('streams an exact-size local multipart part', async () => {
+ const response = await request()
+
+ expect(response.status).toBe(204)
+ expect(mockVerifyUploadSessionToken).toHaveBeenCalledWith('signed-token')
+ expect(mockExpectedUploadPartSize).toHaveBeenCalledWith(SESSION, 1)
+ expect(mockWriteLocalMultipartPart).toHaveBeenCalledWith({
+ uploadId: 'upload-1',
+ partNumber: 1,
+ body: expect.any(ReadableStream),
+ expectedSize: 3,
+ })
+ })
+
+ it('maps a streamed-size failure to 400', async () => {
+ mockWriteLocalMultipartPart.mockRejectedValue(
+ new MockLocalUploadBodyError('Part 1 has 2 bytes; expected 3')
+ )
+
+ const response = await request({ contentLength: null })
+
+ expect(response.status).toBe(400)
+ await expect(response.json()).resolves.toMatchObject({
+ error: 'Part 1 has 2 bytes; expected 3',
+ })
+ })
+
+ it('rejects PUT sessions before calculating a part size', async () => {
+ mockVerifyUploadSessionToken.mockReturnValue({ ...SESSION, method: 'put' })
+
+ const response = await request()
+
+ expect(response.status).toBe(409)
+ expect(mockExpectedUploadPartSize).not.toHaveBeenCalled()
+ expect(mockWriteLocalMultipartPart).not.toHaveBeenCalled()
+ })
+
+ it('rejects expired upload sessions before writing the part', async () => {
+ mockVerifyUploadSessionToken.mockReturnValue({
+ ...SESSION,
+ expiresAt: new Date('2000-01-01T00:00:00.000Z'),
+ })
+
+ const response = await request()
+
+ expect(response.status).toBe(409)
+ await expect(response.json()).resolves.toEqual({ error: 'Upload session has expired' })
+ expect(mockExpectedUploadPartSize).not.toHaveBeenCalled()
+ expect(mockWriteLocalMultipartPart).not.toHaveBeenCalled()
+ })
+})
+
+function request(options?: { contentLength?: string | null }) {
+ const headers = new Headers({ 'Content-Type': 'application/octet-stream' })
+ if (options?.contentLength !== null) {
+ headers.set('Content-Length', options?.contentLength ?? '3')
+ }
+ return PUT(
+ new NextRequest('http://localhost:3000/api/v2/uploads/upload-1/parts/1?token=signed-token', {
+ method: 'PUT',
+ headers,
+ body: new Uint8Array([1, 2, 3]),
+ }),
+ { params: Promise.resolve({ uploadId: 'upload-1', partNumber: '1' }) }
+ )
+}
diff --git a/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts
new file mode 100644
index 00000000000..934e64d839e
--- /dev/null
+++ b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts
@@ -0,0 +1,72 @@
+import { type NextRequest, NextResponse } from 'next/server'
+import { localUploadPartContract } from '@/lib/api/contracts/upload-sessions'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ LocalUploadBodyError,
+ writeLocalMultipartPart,
+} from '@/lib/uploads/upload-session/provider'
+import {
+ expectedUploadPartSize,
+ type UploadSessionRecord,
+ verifyUploadSessionToken,
+} from '@/lib/uploads/upload-session/service'
+
+interface LocalPartRouteParams {
+ params: Promise<{ uploadId: string; partNumber: string }>
+}
+
+/**
+ * Local-storage data plane for signed multipart PUT URLs. Cloud deployments return provider URLs
+ * instead, so this route is never in the cloud byte path.
+ */
+export const PUT = withRouteHandler(
+ async (request: NextRequest, context: LocalPartRouteParams): Promise => {
+ const { uploadId } = await context.params
+ const token = request.nextUrl.searchParams.get('token') ?? ''
+ let session: UploadSessionRecord
+ try {
+ session = await verifyUploadSessionToken(token)
+ } catch {
+ return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 })
+ }
+ const parsed = await parseRequest(localUploadPartContract, request, context)
+ if (!parsed.success) return parsed.response
+
+ if (session.id !== uploadId || session.storageProvider !== 'local') {
+ return NextResponse.json({ error: 'Upload URL does not match this session' }, { status: 403 })
+ }
+ if (session.status !== 'uploading') {
+ return NextResponse.json({ error: `Upload session is ${session.status}` }, { status: 409 })
+ }
+ if (session.expiresAt.getTime() <= Date.now()) {
+ return NextResponse.json({ error: 'Upload session has expired' }, { status: 409 })
+ }
+ if (session.method !== 'multipart') {
+ return NextResponse.json({ error: 'PUT upload sessions do not have parts' }, { status: 409 })
+ }
+
+ const { partNumber } = parsed.data.params
+ const expectedSize = expectedUploadPartSize(session, partNumber)
+ const contentLength = request.headers.get('content-length')
+ if (contentLength !== null && Number(contentLength) !== expectedSize) {
+ return NextResponse.json(
+ { error: `Part ${partNumber} must contain exactly ${expectedSize} bytes` },
+ { status: 400 }
+ )
+ }
+ if (!request.body) {
+ return NextResponse.json({ error: 'Upload part body is required' }, { status: 400 })
+ }
+
+ try {
+ await writeLocalMultipartPart({ uploadId, partNumber, body: request.body, expectedSize })
+ } catch (error) {
+ if (error instanceof LocalUploadBodyError) {
+ return NextResponse.json({ error: error.message }, { status: 400 })
+ }
+ throw error
+ }
+ return new NextResponse(null, { status: 204 })
+ }
+)
diff --git a/apps/sim/app/api/v2/uploads/[uploadId]/route.test.ts b/apps/sim/app/api/v2/uploads/[uploadId]/route.test.ts
new file mode 100644
index 00000000000..71d92a6d22d
--- /dev/null
+++ b/apps/sim/app/api/v2/uploads/[uploadId]/route.test.ts
@@ -0,0 +1,142 @@
+/**
+ * @vitest-environment node
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { MockLocalUploadBodyError, mockGetOwnedUploadSession, mockMetadata, mockWriteLocalPut } =
+ vi.hoisted(() => {
+ class MockLocalUploadBodyError extends Error {}
+ return {
+ MockLocalUploadBodyError,
+ mockGetOwnedUploadSession: vi.fn(),
+ mockMetadata: vi.fn(),
+ mockWriteLocalPut: vi.fn(),
+ }
+ })
+
+vi.mock('@/lib/uploads/upload-session/provider', () => ({
+ LocalUploadBodyError: MockLocalUploadBodyError,
+ writeLocalPutObject: mockWriteLocalPut,
+}))
+
+vi.mock('@/lib/uploads/upload-session/service', () => ({
+ getOwnedUploadSession: mockGetOwnedUploadSession,
+ uploadSessionObjectMetadata: mockMetadata,
+}))
+
+import { PUT } from '@/app/api/v2/uploads/[uploadId]/route'
+
+const SESSION = {
+ id: 'upload-1',
+ workspaceId: 'workspace-1',
+ userId: 'user-1',
+ knowledgeBaseId: null,
+ workflowId: null,
+ executionId: null,
+ purpose: 'workspace_file',
+ method: 'put',
+ storageContext: 'workspace',
+ storageKey: 'workspace/workspace-1/file.bin',
+ finalKey: 'workspace/workspace-1/file.bin',
+ storageProvider: 'local',
+ providerUploadId: null,
+ providerObjectVersion: null,
+ fileName: 'file.bin',
+ contentType: 'application/octet-stream',
+ fileSize: 3,
+ partSize: null,
+ partCount: null,
+ status: 'uploading',
+ metadata: {},
+ uploadToken: 'signed-token',
+ createdAt: new Date('2026-08-04T12:00:00.000Z'),
+ expiresAt: new Date('2099-08-05T12:00:00.000Z'),
+ completedFileId: null,
+ error: null,
+ completedAt: null,
+ updatedAt: new Date('2026-08-04T12:00:00.000Z'),
+} as const
+
+describe('PUT /api/v2/uploads/[uploadId]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetOwnedUploadSession.mockReturnValue(SESSION)
+ mockMetadata.mockReturnValue({ uploadId: 'upload-1', purpose: 'workspace_file' })
+ mockWriteLocalPut.mockResolvedValue(undefined)
+ })
+
+ it('streams the local PUT with the signed session size and canonical metadata', async () => {
+ const response = await request()
+
+ expect(response.status).toBe(204)
+ expect(mockGetOwnedUploadSession).toHaveBeenCalledWith({
+ uploadId: 'upload-1',
+ uploadToken: 'signed-token',
+ })
+ expect(mockWriteLocalPut).toHaveBeenCalledWith({
+ uploadId: 'upload-1',
+ key: 'workspace/workspace-1/file.bin',
+ body: expect.any(ReadableStream),
+ expectedSize: 3,
+ contentType: 'application/octet-stream',
+ metadata: { uploadId: 'upload-1', purpose: 'workspace_file' },
+ })
+ })
+
+ it('streams an empty local PUT body for an empty workspace-file session', async () => {
+ mockGetOwnedUploadSession.mockReturnValue({ ...SESSION, fileSize: 0 })
+ const response = await request({ contentLength: '0', body: new Uint8Array() })
+
+ expect(response.status).toBe(204)
+ expect(mockWriteLocalPut).toHaveBeenCalledWith(
+ expect.objectContaining({ expectedSize: 0, body: expect.any(ReadableStream) })
+ )
+ })
+
+ it('rejects a mismatched Content-Length before opening the local writer', async () => {
+ const response = await request({ contentLength: '2' })
+
+ expect(response.status).toBe(400)
+ await expect(response.json()).resolves.toMatchObject({
+ error: 'Upload must contain exactly 3 bytes',
+ })
+ expect(mockWriteLocalPut).not.toHaveBeenCalled()
+ })
+
+ it('rejects a URL whose token names a non-local or multipart session', async () => {
+ mockGetOwnedUploadSession.mockReturnValue({ ...SESSION, method: 'multipart' })
+
+ const response = await request()
+
+ expect(response.status).toBe(403)
+ expect(mockWriteLocalPut).not.toHaveBeenCalled()
+ })
+
+ it('maps exact-size streaming failures to a caller error', async () => {
+ mockWriteLocalPut.mockRejectedValue(new MockLocalUploadBodyError('Upload exceeds 3 bytes'))
+
+ const response = await request({ contentLength: null })
+
+ expect(response.status).toBe(400)
+ await expect(response.json()).resolves.toMatchObject({ error: 'Upload exceeds 3 bytes' })
+ })
+})
+
+function request(options?: { contentLength?: string | null; body?: Uint8Array }) {
+ const headers = new Headers({
+ 'Content-Type': 'application/octet-stream',
+ 'upload-token': 'signed-token',
+ })
+ if (options?.contentLength !== null) {
+ headers.set('Content-Length', options?.contentLength ?? '3')
+ }
+ return PUT(
+ new NextRequest('http://localhost:3000/api/v2/uploads/upload-1', {
+ method: 'PUT',
+ headers,
+ body: options?.body ?? new Uint8Array([1, 2, 3]),
+ }),
+ { params: Promise.resolve({ uploadId: 'upload-1' }) }
+ )
+}
diff --git a/apps/sim/app/api/v2/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/uploads/[uploadId]/route.ts
new file mode 100644
index 00000000000..52ec40a4cdb
--- /dev/null
+++ b/apps/sim/app/api/v2/uploads/[uploadId]/route.ts
@@ -0,0 +1,76 @@
+import { type NextRequest, NextResponse } from 'next/server'
+import { localPutUploadContract } from '@/lib/api/contracts/upload-sessions'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { LocalUploadBodyError, writeLocalPutObject } from '@/lib/uploads/upload-session/provider'
+import {
+ getOwnedUploadSession,
+ uploadSessionObjectMetadata,
+} from '@/lib/uploads/upload-session/service'
+
+interface LocalPutRouteParams {
+ params: Promise<{ uploadId: string }>
+}
+
+/** Local-storage data plane for a signed whole-object PUT upload session. */
+export const PUT = withRouteHandler(
+ async (request: NextRequest, context: LocalPutRouteParams): Promise => {
+ const parsed = await parseRequest(localPutUploadContract, request, context)
+ if (!parsed.success) return parsed.response
+
+ let session
+ try {
+ session = await getOwnedUploadSession({
+ uploadId: parsed.data.params.uploadId,
+ uploadToken: parsed.data.headers['upload-token'],
+ })
+ } catch {
+ return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 })
+ }
+
+ if (session.storageProvider !== 'local' || session.method !== 'put') {
+ return NextResponse.json({ error: 'Upload URL does not match this session' }, { status: 403 })
+ }
+ if (session.status !== 'uploading') {
+ return NextResponse.json({ error: `Upload session is ${session.status}` }, { status: 409 })
+ }
+ if (session.expiresAt.getTime() <= Date.now()) {
+ return NextResponse.json({ error: 'Upload session has expired' }, { status: 409 })
+ }
+
+ const contentType = request.headers.get('content-type')
+ if (contentType !== session.contentType) {
+ return NextResponse.json(
+ { error: `Content-Type must be ${session.contentType}` },
+ { status: 400 }
+ )
+ }
+ const contentLength = request.headers.get('content-length')
+ if (contentLength !== null && Number(contentLength) !== session.fileSize) {
+ return NextResponse.json(
+ { error: `Upload must contain exactly ${session.fileSize} bytes` },
+ { status: 400 }
+ )
+ }
+ if (!request.body) {
+ return NextResponse.json({ error: 'Upload body is required' }, { status: 400 })
+ }
+
+ try {
+ await writeLocalPutObject({
+ uploadId: session.id,
+ key: session.finalKey,
+ body: request.body,
+ expectedSize: session.fileSize,
+ contentType: session.contentType,
+ metadata: uploadSessionObjectMetadata(session),
+ })
+ } catch (error) {
+ if (error instanceof LocalUploadBodyError) {
+ return NextResponse.json({ error: error.message }, { status: 400 })
+ }
+ throw error
+ }
+ return new NextResponse(null, { status: 204 })
+ }
+)
diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts
new file mode 100644
index 00000000000..ec2479d09b8
--- /dev/null
+++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts
@@ -0,0 +1,176 @@
+import { createLogger } from '@sim/logger'
+import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { v1DeployWorkflowBodySchema } from '@/lib/api/contracts/v1/workflows'
+import {
+ v2DeployWorkflowContract,
+ v2UndeployWorkflowContract,
+} from '@/lib/api/contracts/v2/workflows'
+import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { captureServerEvent } from '@/lib/posthog/server'
+import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration'
+import { checkRateLimit } from '@/app/api/v1/middleware'
+import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2WorkflowDeployAPI')
+
+export const dynamic = 'force-dynamic'
+export const runtime = 'nodejs'
+export const maxDuration = 120
+
+export const POST = withRouteHandler(
+ async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'workflow-deploy')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2DeployWorkflowContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+
+ const rawBody = await parseOptionalJsonBody(request)
+ if (!rawBody.success) {
+ return rawBody.response.status === 413
+ ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large')
+ : v2Error('BAD_REQUEST', 'Request body must be valid JSON')
+ }
+ const body = v1DeployWorkflowBodySchema.safeParse(rawBody.data ?? {})
+ if (!body.success) return v2ValidationError(body.error)
+
+ const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id)
+ if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found')
+ const { workspaceId } = target
+
+ await assertWorkflowMutable(id)
+
+ logger.info(`[${requestId}] Deploying workflow ${id} via v2 API`, { userId })
+
+ const result = await performFullDeploy({
+ workflowId: id,
+ userId,
+ versionName: body.data.name,
+ versionDescription: body.data.description ?? undefined,
+ requestId,
+ })
+
+ if (!result.success) {
+ const code =
+ result.errorCode === 'not_found'
+ ? 'NOT_FOUND'
+ : result.errorCode === 'validation'
+ ? 'BAD_REQUEST'
+ : 'INTERNAL_ERROR'
+ return v2Error(code, result.error || 'Failed to deploy workflow')
+ }
+
+ captureServerEvent(
+ userId,
+ 'workflow_deployed',
+ { workflow_id: id, workspace_id: workspaceId },
+ {
+ groups: { workspace: workspaceId },
+ setOnce: { first_workflow_deployed_at: new Date().toISOString() },
+ }
+ )
+
+ return v2Data(
+ {
+ id,
+ isDeployed: true,
+ deployedAt: result.deployedAt?.toISOString() ?? null,
+ version: result.version,
+ warnings: result.warnings ?? [],
+ },
+ { rateLimit }
+ )
+ } catch (error) {
+ if (error instanceof WorkflowLockedError) {
+ return v2Error('LOCKED', error.message)
+ }
+ logger.error(`[${requestId}] Workflow deploy error`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
+
+export const DELETE = withRouteHandler(
+ async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'workflow-deploy')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2UndeployWorkflowContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+
+ const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id)
+ if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found')
+ const { workflow, workspaceId } = target
+
+ if (!workflow.isDeployed) {
+ return v2Error('BAD_REQUEST', 'Workflow is not deployed')
+ }
+
+ await assertWorkflowMutable(id)
+
+ logger.info(`[${requestId}] Undeploying workflow ${id} via v2 API`, { userId })
+
+ const result = await performFullUndeploy({ workflowId: id, userId, requestId })
+ if (!result.success) {
+ return v2Error('INTERNAL_ERROR', result.error || 'Failed to undeploy workflow')
+ }
+
+ captureServerEvent(
+ userId,
+ 'workflow_undeployed',
+ { workflow_id: id, workspace_id: workspaceId },
+ { groups: { workspace: workspaceId } }
+ )
+
+ return v2Data(
+ {
+ id,
+ isDeployed: false,
+ deployedAt: null,
+ warnings: result.warnings ?? [],
+ },
+ { rateLimit }
+ )
+ } catch (error) {
+ if (error instanceof WorkflowLockedError) {
+ return v2Error('LOCKED', error.message)
+ }
+ logger.error(`[${requestId}] Workflow undeploy error`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts
new file mode 100644
index 00000000000..757b09e7388
--- /dev/null
+++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts
@@ -0,0 +1,404 @@
+/**
+ * @vitest-environment node
+ */
+
+import {
+ createMockRequest,
+ dbChainMockFns,
+ executionPreprocessingMock,
+ executionPreprocessingMockFns,
+ loggingSessionMock,
+ resetDbChainMock,
+ setEnv,
+ workflowAuthzMockFns,
+ workflowsPersistenceUtilsMock,
+ workflowsPersistenceUtilsMockFns,
+ workflowsUtilsMock,
+} from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockAuthenticateV1Request,
+ mockClaimExecutionId,
+ mockEnqueue,
+ mockExecuteWorkflowCore,
+ mockGenerateId,
+ mockGetWorkspaceBillingSettings,
+ mockHasDurableExecutionOwner,
+ mockReleaseExecutionIdClaim,
+ mockReleaseExecutionSlot,
+ mockValidatePublicApiAllowed,
+} = vi.hoisted(() => ({
+ mockAuthenticateV1Request: vi.fn(),
+ mockClaimExecutionId: vi.fn(),
+ mockEnqueue: vi.fn().mockResolvedValue('workflow-execution:execution-123'),
+ mockExecuteWorkflowCore: vi.fn(),
+ mockGenerateId: vi.fn(() => 'execution-123'),
+ mockGetWorkspaceBillingSettings: vi.fn(),
+ mockHasDurableExecutionOwner: vi.fn(),
+ mockReleaseExecutionIdClaim: vi.fn(),
+ mockReleaseExecutionSlot: vi.fn(),
+ mockValidatePublicApiAllowed: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/auth', () => ({
+ authenticateV1Request: mockAuthenticateV1Request,
+}))
+
+vi.mock('@/lib/billing/calculations/usage-reservation', () => ({
+ releaseExecutionSlot: mockReleaseExecutionSlot,
+}))
+
+vi.mock('@/lib/workspaces/utils', () => ({
+ getWorkspaceBillingSettings: mockGetWorkspaceBillingSettings,
+}))
+
+vi.mock('@/ee/access-control/utils/permission-check', () => ({
+ PublicApiNotAllowedError: class PublicApiNotAllowedError extends Error {},
+ validatePublicApiAllowed: mockValidatePublicApiAllowed,
+}))
+
+vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
+vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock)
+vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock)
+vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock)
+
+vi.mock('@/lib/workflows/executor/execution-core', () => ({
+ executeWorkflowCore: mockExecuteWorkflowCore,
+}))
+
+vi.mock('@/lib/workflows/executor/pause-persistence', () => ({
+ handlePostExecutionPauseState: vi.fn(),
+}))
+
+vi.mock('@/lib/workflows/executor/execution-id-claim', () => ({
+ claimExecutionId: mockClaimExecutionId,
+ hasDurableExecutionOwner: mockHasDurableExecutionOwner,
+ releaseExecutionIdClaim: mockReleaseExecutionIdClaim,
+}))
+
+vi.mock('@/lib/core/async-jobs', () => ({
+ getJobQueue: vi.fn().mockResolvedValue({
+ enqueue: mockEnqueue,
+ startJob: vi.fn(),
+ completeJob: vi.fn(),
+ markJobFailed: vi.fn(),
+ }),
+ shouldExecuteInline: vi.fn().mockReturnValue(false),
+}))
+
+vi.mock('@/background/workflow-execution', () => ({
+ executeWorkflowJob: vi.fn(),
+}))
+
+vi.mock('@/lib/workflows/custom-blocks/operations', () => ({
+ getCustomBlockRowsForWorkspace: vi.fn().mockResolvedValue([]),
+}))
+
+vi.mock('@/blocks/custom/server-overlay', () => ({
+ withCustomBlockOverlay: vi.fn(async (_rows: unknown, fn: () => unknown) => fn()),
+}))
+
+vi.mock('@/serializer', () => ({
+ Serializer: class {
+ serializeWorkflow() {
+ return { blocks: [] }
+ }
+ },
+}))
+
+vi.mock('@/lib/execution/files', () => ({
+ processInputFileFields: vi.fn(async (input: unknown) => input),
+}))
+
+vi.mock('@/lib/uploads/utils/user-file-base64.server', () => ({
+ hydrateUserFilesWithBase64: vi.fn(async (output: unknown) => output),
+}))
+
+vi.mock('@/lib/execution/payloads/serializer', () => ({
+ compactExecutionPayload: vi.fn(async (value: unknown) => value),
+}))
+
+vi.mock(import('@/lib/execution/payloads/large-value-ref'), async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, containsLargeValueRef: vi.fn().mockReturnValue(false) }
+})
+
+vi.mock('@sim/utils/id', () => ({
+ generateId: mockGenerateId,
+ generateShortId: vi.fn(() => 'mock-short-id'),
+ isValidUuid: vi.fn((v: string) =>
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v)
+ ),
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+import { attachExecutionResult } from '@/executor/utils/errors'
+import { POST } from './route'
+
+const mockPreprocessExecution = executionPreprocessingMockFns.mockPreprocessExecution
+const mockAuthorize = workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission
+const mockLoadDeployedWorkflowState = workflowsPersistenceUtilsMockFns.mockLoadDeployedWorkflowState
+
+const billingAttribution = {
+ actorUserId: 'actor-1',
+ workspaceId: 'workspace-1',
+ organizationId: null,
+ billedAccountUserId: 'actor-1',
+ billingEntity: { type: 'user' as const, id: 'actor-1' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+ payerSubscription: null,
+}
+
+const workflowRecord = {
+ id: 'workflow-1',
+ userId: 'owner-1',
+ workspaceId: 'workspace-1',
+ isDeployed: true,
+ variables: {},
+}
+
+function callExecute(body: Record, headers: Record = {}) {
+ const req = createMockRequest('POST', body, {
+ 'Content-Type': 'application/json',
+ ...headers,
+ })
+ return POST(req, { params: Promise.resolve({ id: 'workflow-1' }) })
+}
+
+describe('POST /api/v2/workflows/[id]/execute', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resetDbChainMock()
+ setEnv({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' })
+ mockGenerateId.mockReturnValue('execution-123')
+ mockAuthenticateV1Request.mockResolvedValue({
+ authenticated: true,
+ userId: 'key-user-1',
+ keyType: 'workspace',
+ workspaceId: 'workspace-1',
+ })
+ mockAuthorize.mockResolvedValue({ allowed: true, workflow: workflowRecord })
+ mockClaimExecutionId.mockImplementation(async (executionId: string) => ({
+ key: `workflow-execution-id:${executionId}`,
+ token: `token-${executionId}`,
+ }))
+ mockHasDurableExecutionOwner.mockResolvedValue(false)
+ mockPreprocessExecution.mockResolvedValue({
+ success: true,
+ actorUserId: 'actor-1',
+ workflowRecord,
+ actorSubscription: { plan: 'pro' },
+ billingAttribution,
+ executionTimeout: { sync: 60_000, async: 300_000 },
+ })
+ mockLoadDeployedWorkflowState.mockResolvedValue({
+ blocks: {},
+ edges: [],
+ loops: {},
+ parallels: {},
+ variables: {},
+ })
+ mockExecuteWorkflowCore.mockResolvedValue({
+ success: true,
+ output: { result: 'done' },
+ metadata: {
+ duration: 42,
+ startTime: '2026-07-31T00:00:00.000Z',
+ endTime: '2026-07-31T00:00:01.000Z',
+ },
+ })
+ })
+
+ it('runs sync and returns the execution resource in the v2 envelope', async () => {
+ const res = await callExecute({ input: { hello: 'world' } })
+
+ expect(res.status).toBe(200)
+ expect(res.headers.get('X-Execution-Id')).toBe('execution-123')
+ const body = await res.json()
+ expect(body.data).toMatchObject({
+ executionId: 'execution-123',
+ workflowId: 'workflow-1',
+ status: 'completed',
+ output: { result: 'done' },
+ error: null,
+ durationMs: 42,
+ })
+ })
+
+ it('returns status failed with a structured error instead of an HTTP error', async () => {
+ const error = new Error('Send Email: Invalid credentials')
+ Object.assign(error, { blockId: 'block-9', blockName: 'Send Email', blockType: 'gmail' })
+ attachExecutionResult(error, {
+ success: false,
+ output: { partial: true },
+ metadata: { duration: 10, startTime: 's', endTime: 'e' },
+ })
+ mockExecuteWorkflowCore.mockRejectedValue(error)
+
+ const res = await callExecute({ input: {} })
+
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.data.status).toBe('failed')
+ expect(body.data.executionId).toBe('execution-123')
+ expect(body.data.output).toEqual({ partial: true })
+ expect(body.data.error).toEqual({
+ message: 'Invalid credentials',
+ code: 'BLOCK_EXECUTION_FAILED',
+ blockId: 'block-9',
+ blockName: 'Send Email',
+ blockType: 'gmail',
+ })
+ })
+
+ it('queues async runs and returns a 202 receipt with the v2 executions statusUrl', async () => {
+ const res = await callExecute({ input: {}, async: true })
+
+ expect(res.status).toBe(202)
+ const body = await res.json()
+ expect(body.data).toEqual({
+ executionId: 'execution-123',
+ statusUrl: 'http://localhost:3000/api/v2/workflows/workflow-1/executions/execution-123',
+ })
+ expect(mockPreprocessExecution).toHaveBeenCalledWith(
+ expect.objectContaining({ rateLimitCounter: 'async' })
+ )
+ })
+
+ it('404s the whole surface when the v2-api flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callExecute({ input: {} })
+
+ expect(res.status).toBe(404)
+ expect((await res.json()).error.code).toBe('NOT_FOUND')
+ expect(mockPreprocessExecution).not.toHaveBeenCalled()
+ })
+
+ it('rejects unknown body keys (strict contract)', async () => {
+ const res = await callExecute({ input: {}, triggerType: 'manual' })
+
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(mockPreprocessExecution).not.toHaveBeenCalled()
+ })
+
+ it('rejects async combined with stream or output-shaping options', async () => {
+ expect((await callExecute({ async: true, stream: true })).status).toBe(400)
+ expect((await callExecute({ async: true, selectedOutputs: ['a.b'] })).status).toBe(400)
+ expect((await callExecute({ async: true, includeFileBase64: true })).status).toBe(400)
+ expect(mockPreprocessExecution).not.toHaveBeenCalled()
+ })
+
+ it('masks a workspace-key/workflow mismatch as 404', async () => {
+ mockAuthenticateV1Request.mockResolvedValue({
+ authenticated: true,
+ userId: 'key-user-1',
+ keyType: 'workspace',
+ workspaceId: 'other-workspace',
+ })
+
+ const res = await callExecute({ input: {} })
+
+ expect(res.status).toBe(404)
+ expect((await res.json()).error.code).toBe('NOT_FOUND')
+ })
+
+ it('rejects personal keys when the workspace disallows them', async () => {
+ mockAuthenticateV1Request.mockResolvedValue({
+ authenticated: true,
+ userId: 'key-user-1',
+ keyType: 'personal',
+ })
+ mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: false })
+
+ const res = await callExecute({ input: {} })
+
+ expect(res.status).toBe(403)
+ })
+
+ it('returns 409 CONFLICT for a reused X-Execution-Id', async () => {
+ mockClaimExecutionId.mockResolvedValue(null)
+
+ const res = await callExecute(
+ { input: {} },
+ { 'X-Execution-Id': '11111111-1111-4111-8111-111111111111' }
+ )
+
+ expect(res.status).toBe(409)
+ const body = await res.json()
+ expect(body.error.code).toBe('CONFLICT')
+ expect(body.error.details).toMatchObject({
+ code: 'EXECUTION_ID_CONFLICT',
+ executionId: '11111111-1111-4111-8111-111111111111',
+ })
+ })
+
+ it('surfaces the rate-limit failure with Retry-After', async () => {
+ mockPreprocessExecution.mockResolvedValue({
+ success: false,
+ error: {
+ message: 'Rate limit exceeded. Please try again later.',
+ statusCode: 429,
+ code: 'RATE_LIMIT_EXCEEDED',
+ retryAfterMs: 12_000,
+ },
+ })
+
+ const res = await callExecute({ input: {} })
+
+ expect(res.status).toBe(429)
+ expect(res.headers.get('Retry-After')).toBe('12')
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('runs the anonymous public path sync but refuses async', async () => {
+ mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' })
+ dbChainMockFns.limit.mockResolvedValueOnce([
+ { isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' },
+ ])
+
+ const okRes = await callExecute({ input: {} })
+ expect(okRes.status).toBe(200)
+
+ mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' })
+ dbChainMockFns.limit.mockResolvedValueOnce([
+ { isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' },
+ ])
+ const asyncRes = await callExecute({ input: {}, async: true })
+ expect(asyncRes.status).toBe(400)
+ })
+
+ it('401s non-public workflows without a key', async () => {
+ mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' })
+ dbChainMockFns.limit.mockResolvedValueOnce([
+ { isPublicApi: false, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' },
+ ])
+
+ const res = await callExecute({ input: {} })
+
+ expect(res.status).toBe(401)
+ expect((await res.json()).error.code).toBe('UNAUTHORIZED')
+ })
+
+ it('releases the unused execution-id claim after a failed preprocess', async () => {
+ mockPreprocessExecution.mockResolvedValue({
+ success: false,
+ error: { message: 'Workflow not found', statusCode: 404 },
+ })
+
+ const res = await callExecute({ input: {} })
+
+ expect(res.status).toBe(404)
+ expect(mockReleaseExecutionIdClaim).toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts
new file mode 100644
index 00000000000..ebe26b9ac2e
--- /dev/null
+++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts
@@ -0,0 +1,290 @@
+import { db } from '@sim/db'
+import { workflow as workflowTable } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
+import { getErrorMessage } from '@sim/utils/errors'
+import { eq } from 'drizzle-orm'
+import type { NextRequest } from 'next/server'
+import { v2ExecuteWorkflowContract } from '@/lib/api/contracts/v2/workflows'
+import { executionIdSchema, WORKFLOW_EXECUTION_ID_HEADER } from '@/lib/api/contracts/workflows'
+import { parseRequest } from '@/lib/api/server'
+import { tryAdmit } from '@/lib/core/admission/gate'
+import { ADMISSION_ERROR_DESCRIPTOR } from '@/lib/core/admission/transient-failure'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { getBaseUrl } from '@/lib/core/utils/urls'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ type ExecuteWorkflowServiceFailure,
+ executeWorkflowService,
+} from '@/lib/workflows/executor/execute-service'
+import {
+ AGENT_STREAM_PROTOCOL_HEADER_LABEL,
+ AGENT_STREAM_PROTOCOL_V1,
+ clientAcceptsAgentStreamProtocol,
+ hasAgentStreamPolicy,
+} from '@/lib/workflows/streaming/agent-stream-protocol'
+import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils'
+import { authenticateV1Request } from '@/app/api/v1/auth'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import { type V2ErrorCode, v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response'
+import {
+ PublicApiNotAllowedError,
+ validatePublicApiAllowed,
+} from '@/ee/access-control/utils/permission-check'
+
+const logger = createLogger('V2WorkflowExecuteAPI')
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+const FAILURE_CODE_BY_STATUS: Record = {
+ 400: 'BAD_REQUEST',
+ 401: 'UNAUTHORIZED',
+ 402: 'USAGE_LIMIT_EXCEEDED',
+ 403: 'FORBIDDEN',
+ 404: 'NOT_FOUND',
+ 408: 'BAD_REQUEST',
+ 409: 'CONFLICT',
+ 413: 'PAYLOAD_TOO_LARGE',
+ 429: 'RATE_LIMITED',
+ 499: 'CLIENT_CLOSED_REQUEST',
+ 503: 'SERVICE_UNAVAILABLE',
+}
+
+function serviceFailureResponse(failure: ExecuteWorkflowServiceFailure) {
+ const code = FAILURE_CODE_BY_STATUS[failure.statusCode] ?? 'INTERNAL_ERROR'
+ const headers: Record = {}
+ if (failure.retryAfterMs !== undefined) {
+ headers['Retry-After'] = Math.max(1, Math.ceil(failure.retryAfterMs / 1000)).toString()
+ }
+ if (failure.executionId) {
+ headers[WORKFLOW_EXECUTION_ID_HEADER] = failure.executionId
+ }
+ return v2Error(code, failure.message, {
+ status: failure.statusCode,
+ headers,
+ details:
+ failure.code || failure.executionId
+ ? {
+ ...(failure.code ? { code: failure.code } : {}),
+ ...(failure.executionId ? { executionId: failure.executionId } : {}),
+ }
+ : undefined,
+ })
+}
+
+/**
+ * POST /api/v2/workflows/[id]/execute — syntactic sugar over
+ * {@link executeWorkflowService}.
+ *
+ * - Auth: `X-API-Key` (personal/workspace) or the anonymous public-API path for
+ * workflows deployed with `isPublicApi` (actor = owner; sync/stream only).
+ * - `async: true` (body flag — v2 has no mode headers) → 202
+ * `{ data: { executionId, statusUrl } }`; poll the v2 executions resource.
+ * - `stream: true` → SSE passthrough (no `{data}` envelope on event frames).
+ * - Sync → 200 execution resource with the status enum and structured error;
+ * an in-band run failure is `status: 'failed'`, never an HTTP error. A
+ * Response block's declared payload stays inside `output` — v2 never lets a
+ * workflow author control response status or headers on this origin.
+ * - Rate limiting: the execution `sync`/`async` buckets via preprocessing —
+ * deliberately NOT the shared `api-endpoint` bucket, and async runs debit
+ * the async bucket (unlike v1's known sync-bucket bug).
+ */
+export const POST = withRouteHandler(
+ async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
+ const requestId = generateRequestId()
+ const { id: workflowId } = await context.params
+
+ let userId: string
+ let isPublicApiAccess = false
+ let apiKeyType: 'personal' | 'workspace' | undefined
+ let apiKeyWorkspaceId: string | undefined
+
+ const auth = await authenticateV1Request(req)
+ if (auth.authenticated && auth.userId) {
+ userId = auth.userId
+ apiKeyType = auth.keyType
+ apiKeyWorkspaceId = auth.workspaceId
+ } else {
+ if (req.headers.has('x-api-key')) {
+ return v2Error('UNAUTHORIZED', auth.error || 'Unauthorized')
+ }
+ const [wf] = await db
+ .select({
+ isPublicApi: workflowTable.isPublicApi,
+ isDeployed: workflowTable.isDeployed,
+ userId: workflowTable.userId,
+ workspaceId: workflowTable.workspaceId,
+ })
+ .from(workflowTable)
+ .where(eq(workflowTable.id, workflowId))
+ .limit(1)
+
+ if (!wf?.isPublicApi || !wf.isDeployed || !wf.workspaceId) {
+ return v2Error('UNAUTHORIZED', 'Unauthorized')
+ }
+ try {
+ await validatePublicApiAllowed(wf.userId, wf.workspaceId)
+ } catch (err) {
+ if (err instanceof PublicApiNotAllowedError) {
+ return v2Error('UNAUTHORIZED', 'Unauthorized')
+ }
+ throw err
+ }
+ userId = wf.userId
+ isPublicApiAccess = true
+ }
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const ticket = tryAdmit()
+ if (!ticket) {
+ return v2Error('RATE_LIMITED', 'Server is at capacity. Please retry shortly.', {
+ headers: {
+ 'Retry-After': ADMISSION_ERROR_DESCRIPTOR.GATE_CAPACITY.retryAfterSeconds.toString(),
+ },
+ })
+ }
+
+ try {
+ const parsed = await parseRequest(v2ExecuteWorkflowContract, req, context, {
+ maxBodyBytes: 10 * 1024 * 1024,
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+ const body = parsed.data.body
+
+ if (body.async && isPublicApiAccess) {
+ return v2Error('BAD_REQUEST', 'Async execution requires an API key')
+ }
+ if (body.async && body.stream) {
+ return v2Error('BAD_REQUEST', 'async and stream cannot be combined')
+ }
+ if (
+ body.async &&
+ (body.selectedOutputs?.length ||
+ body.includeThinking ||
+ body.includeToolCalls ||
+ body.includeFileBase64 !== undefined ||
+ body.base64MaxBytes !== undefined)
+ ) {
+ return v2Error(
+ 'BAD_REQUEST',
+ 'Async execution does not support streaming or output-shaping options'
+ )
+ }
+ if (
+ hasAgentStreamPolicy({
+ includeThinking: body.includeThinking,
+ includeToolCalls: body.includeToolCalls,
+ }) &&
+ !clientAcceptsAgentStreamProtocol(req.headers)
+ ) {
+ return v2Error(
+ 'BAD_REQUEST',
+ `includeThinking and includeToolCalls require the ${AGENT_STREAM_PROTOCOL_HEADER_LABEL}: ${AGENT_STREAM_PROTOCOL_V1} request header, which declares that the client understands agent-event frames.`
+ )
+ }
+
+ /** Idempotent execution ids are a keyed-caller feature; anonymous callers must not probe the claim table. */
+ let requestedExecutionId: string | undefined
+ const executionIdHeader = req.headers.get(WORKFLOW_EXECUTION_ID_HEADER)
+ if (executionIdHeader !== null && !isPublicApiAccess) {
+ const headerValidation = executionIdSchema.safeParse(executionIdHeader)
+ if (!headerValidation.success) {
+ return v2Error('BAD_REQUEST', 'Invalid execution ID header')
+ }
+ requestedExecutionId = headerValidation.data
+ }
+
+ const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
+ workflowId,
+ userId,
+ action: 'read',
+ })
+ // Mask authorization failures as 404 so cross-workspace existence never leaks.
+ if (!workflowAuthorization.allowed || !workflowAuthorization.workflow) {
+ return v2Error('NOT_FOUND', 'Workflow not found')
+ }
+ const workflowRecord = workflowAuthorization.workflow
+
+ if (apiKeyType === 'workspace' && workflowRecord.workspaceId !== apiKeyWorkspaceId) {
+ return v2Error('NOT_FOUND', 'Workflow not found')
+ }
+ if (apiKeyType === 'personal' && workflowRecord.workspaceId) {
+ const settings = await getWorkspaceBillingSettings(workflowRecord.workspaceId)
+ if (!settings?.allowPersonalApiKeys) {
+ return v2Error('FORBIDDEN', 'Personal API keys are not allowed for this workspace')
+ }
+ }
+
+ const result = await executeWorkflowService({
+ workflowId,
+ userId,
+ input: body.input ?? {},
+ triggerType: 'api',
+ requestId,
+ executionId: requestedExecutionId,
+ useAuthenticatedUserAsActor: apiKeyType === 'personal',
+ workflowRecord,
+ includeFileBase64: body.includeFileBase64,
+ base64MaxBytes: body.base64MaxBytes,
+ selectedOutputs: body.selectedOutputs,
+ rateLimitCounter: body.async ? 'async' : 'sync',
+ abortSignal: req.signal,
+ mode: body.async ? 'async' : body.stream ? 'stream' : 'sync',
+ requestHeaders: req.headers,
+ includeThinking: body.includeThinking,
+ includeToolCalls: body.includeToolCalls,
+ })
+
+ if (!result.ok) {
+ return serviceFailureResponse(result.failure)
+ }
+
+ if ('stream' in result) {
+ // SSE: pass the stream through byte-for-byte with its own headers.
+ return result.stream
+ }
+
+ if ('queued' in result) {
+ return v2Data(
+ {
+ executionId: result.executionId,
+ statusUrl: `${getBaseUrl()}/api/v2/workflows/${workflowId}/executions/${result.executionId}`,
+ },
+ { status: 202, headers: { [WORKFLOW_EXECUTION_ID_HEADER]: result.executionId } }
+ )
+ }
+
+ if (result.aborted === 'client') {
+ return v2Error('CLIENT_CLOSED_REQUEST', 'Client cancelled request', {
+ details: { executionId: result.executionId },
+ })
+ }
+
+ return v2Data(
+ {
+ executionId: result.executionId,
+ workflowId: result.workflowId,
+ status: result.status,
+ output: result.output ?? null,
+ error: result.error,
+ startedAt: result.startedAt,
+ endedAt: result.endedAt,
+ durationMs: result.durationMs,
+ },
+ { headers: { [WORKFLOW_EXECUTION_ID_HEADER]: result.executionId } }
+ )
+ } catch (error) {
+ logger.error(`[${requestId}] v2 execute failed`, {
+ workflowId,
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ } finally {
+ ticket.release()
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/cancel/route.ts b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/cancel/route.ts
new file mode 100644
index 00000000000..2d3ebe1166e
--- /dev/null
+++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/cancel/route.ts
@@ -0,0 +1,48 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { v2CancelWorkflowExecutionContract } from '@/lib/api/contracts/v2/workflows'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { cancelWorkflowExecution } from '@/lib/execution/cancel-workflow-execution'
+import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response'
+import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access'
+
+const logger = createLogger('V2CancelExecutionAPI')
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/** POST /api/v2/workflows/[id]/executions/[executionId]/cancel */
+export const POST = withRouteHandler(
+ async (req: NextRequest, context: { params: Promise<{ id: string; executionId: string }> }) => {
+ const parsed = await parseRequest(v2CancelWorkflowExecutionContract, req, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+ const { id: workflowId, executionId } = parsed.data.params
+
+ const access = await resolveV2WorkflowAccess(req, workflowId, 'write')
+ if (!access.ok) return access.response
+
+ try {
+ logger.info('Cancel execution requested', { workflowId, executionId, userId: access.userId })
+
+ const result = await cancelWorkflowExecution({
+ executionId,
+ workflowId,
+ userId: access.userId,
+ workspaceId: access.workflow.workspaceId ?? undefined,
+ })
+
+ return v2Data(result)
+ } catch (error) {
+ logger.error('Failed to cancel execution', {
+ workflowId,
+ executionId,
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts
new file mode 100644
index 00000000000..c0e96fc8080
--- /dev/null
+++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts
@@ -0,0 +1,175 @@
+/**
+ * @vitest-environment node
+ */
+import { createMockRequest, workflowAuthzMockFns } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockAuthenticateV1Request, mockGetJob, mockGetWorkflowExecutionStatus, mockCancel } =
+ vi.hoisted(() => ({
+ mockAuthenticateV1Request: vi.fn(),
+ mockGetJob: vi.fn(),
+ mockGetWorkflowExecutionStatus: vi.fn(),
+ mockCancel: vi.fn(),
+ }))
+
+vi.mock('@/app/api/v1/auth', () => ({
+ authenticateV1Request: mockAuthenticateV1Request,
+}))
+
+vi.mock('@/lib/workspaces/utils', () => ({
+ getWorkspaceBillingSettings: vi.fn().mockResolvedValue({ allowPersonalApiKeys: true }),
+}))
+
+vi.mock('@/lib/workflows/executor/execution-status', () => ({
+ getWorkflowExecutionStatus: mockGetWorkflowExecutionStatus,
+}))
+
+vi.mock('@/lib/execution/cancel-workflow-execution', () => ({
+ cancelWorkflowExecution: mockCancel,
+}))
+
+vi.mock('@/lib/core/async-jobs', () => ({
+ getJobQueue: vi.fn().mockResolvedValue({ getJob: mockGetJob }),
+}))
+
+vi.mock('@/lib/workflows/executor/enqueue-execution', () => ({
+ WORKFLOW_EXECUTION_JOB_ID_PREFIX: 'workflow-execution:',
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+import { POST as cancelPost } from './cancel/route'
+import { GET } from './route'
+
+const mockAuthorize = workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission
+
+const workflowRecord = {
+ id: 'workflow-1',
+ userId: 'owner-1',
+ workspaceId: 'workspace-1',
+}
+
+function callStatus(query = '') {
+ const req = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ `http://localhost:3000/api/v2/workflows/workflow-1/executions/exec-1${query}`
+ )
+ return GET(req, { params: Promise.resolve({ id: 'workflow-1', executionId: 'exec-1' }) })
+}
+
+describe('v2 executions status + cancel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockAuthenticateV1Request.mockResolvedValue({
+ authenticated: true,
+ userId: 'key-user-1',
+ keyType: 'workspace',
+ workspaceId: 'workspace-1',
+ })
+ mockAuthorize.mockResolvedValue({ allowed: true, workflow: workflowRecord })
+ })
+
+ it('returns the execution resource with a structured error', async () => {
+ mockGetWorkflowExecutionStatus.mockResolvedValue({
+ executionId: 'exec-1',
+ workflowId: 'workflow-1',
+ status: 'failed',
+ trigger: 'api',
+ level: 'error',
+ startedAt: '2026-07-31T00:00:00.000Z',
+ endedAt: '2026-07-31T00:00:05.000Z',
+ totalDurationMs: 5000,
+ paused: null,
+ cost: { total: 0.02 },
+ error: 'Send Email: Invalid credentials',
+ finalOutput: null,
+ blockOutputs: null,
+ })
+
+ const res = await callStatus()
+
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.data.status).toBe('failed')
+ expect(body.data.error.code).toBe('EXECUTION_FAILED')
+ expect(body.data.error.message).toBe('Send Email: Invalid credentials')
+ expect(body.data.durationMs).toBe(5000)
+ })
+
+ it('backfills queued status from the job queue before the log row exists', async () => {
+ mockGetWorkflowExecutionStatus.mockResolvedValue(null)
+ mockGetJob.mockResolvedValue({
+ status: 'pending',
+ metadata: { workflowId: 'workflow-1' },
+ })
+
+ const res = await callStatus()
+
+ expect(res.status).toBe(200)
+ expect((await res.json()).data.status).toBe('queued')
+ expect(mockGetJob).toHaveBeenCalledWith('workflow-execution:exec-1')
+ })
+
+ it('404s when neither a log row nor a matching job exists', async () => {
+ mockGetWorkflowExecutionStatus.mockResolvedValue(null)
+ mockGetJob.mockResolvedValue(null)
+
+ const res = await callStatus()
+
+ expect(res.status).toBe(404)
+ expect((await res.json()).error.code).toBe('NOT_FOUND')
+ })
+
+ it('masks cross-workspace access as 404', async () => {
+ mockAuthenticateV1Request.mockResolvedValue({
+ authenticated: true,
+ userId: 'key-user-1',
+ keyType: 'workspace',
+ workspaceId: 'other-workspace',
+ })
+
+ const res = await callStatus()
+
+ expect(res.status).toBe(404)
+ expect(mockGetWorkflowExecutionStatus).not.toHaveBeenCalled()
+ })
+
+ it('cancels through the shared lib and returns the tightened result', async () => {
+ mockCancel.mockResolvedValue({
+ success: true,
+ executionId: 'exec-1',
+ redisAvailable: true,
+ durablyRecorded: true,
+ locallyAborted: false,
+ pausedCancelled: false,
+ reason: 'recorded',
+ })
+
+ const req = createMockRequest('POST', undefined, {})
+ const res = await cancelPost(req, {
+ params: Promise.resolve({ id: 'workflow-1', executionId: 'exec-1' }),
+ })
+
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.data).toMatchObject({ success: true, reason: 'recorded' })
+ expect(mockCancel).toHaveBeenCalledWith({
+ executionId: 'exec-1',
+ workflowId: 'workflow-1',
+ userId: 'key-user-1',
+ workspaceId: 'workspace-1',
+ })
+ })
+
+ it('401s without an API key (no session/anonymous path on executions)', async () => {
+ mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' })
+
+ const res = await callStatus()
+
+ expect(res.status).toBe(401)
+ })
+})
diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.ts b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.ts
new file mode 100644
index 00000000000..d38da03a1b5
--- /dev/null
+++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.ts
@@ -0,0 +1,130 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import {
+ type V2WorkflowExecutionStatus,
+ v2GetWorkflowExecutionContract,
+} from '@/lib/api/contracts/v2/workflows'
+import { parseRequest } from '@/lib/api/server'
+import { getJobQueue } from '@/lib/core/async-jobs'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE,
+ FunctionalOutputsUnavailableError,
+} from '@/lib/logs/execution/functional-outputs'
+import { WORKFLOW_EXECUTION_JOB_ID_PREFIX } from '@/lib/workflows/executor/enqueue-execution'
+import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status'
+import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response'
+import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access'
+import { classifyExecutionError } from '@/executor/utils/errors'
+
+const logger = createLogger('V2WorkflowExecutionStatusAPI')
+
+export const dynamic = 'force-dynamic'
+
+/**
+ * Maps the async job's phase onto the execution status enum for the window
+ * before the worker writes the durable log row.
+ */
+function jobStatusToExecutionStatus(jobStatus: string): V2WorkflowExecutionStatus['status'] | null {
+ switch (jobStatus) {
+ case 'pending':
+ return 'queued'
+ case 'processing':
+ return 'running'
+ case 'failed':
+ return 'failed'
+ case 'completed':
+ return 'completed'
+ default:
+ return null
+ }
+}
+
+/**
+ * GET /api/v2/workflows/[id]/executions/[executionId] — the single status URL
+ * for both sync and async runs. When no log row exists yet, the async job
+ * queue is consulted (deterministic job id) so a freshly-queued run reports
+ * `queued` instead of 404.
+ */
+export const GET = withRouteHandler(
+ async (
+ request: NextRequest,
+ context: { params: Promise<{ id: string; executionId: string }> }
+ ) => {
+ const parsed = await parseRequest(v2GetWorkflowExecutionContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+ const { id: workflowId, executionId } = parsed.data.params
+ const { includeOutput, selectedOutputs } = parsed.data.query
+
+ const access = await resolveV2WorkflowAccess(request, workflowId, 'read')
+ if (!access.ok) return access.response
+
+ try {
+ const status = await getWorkflowExecutionStatus({
+ workflowId,
+ executionId,
+ includeOutput,
+ selectedOutputs,
+ })
+
+ if (status) {
+ return v2Data({
+ executionId: status.executionId,
+ workflowId: status.workflowId,
+ status: status.status,
+ trigger: status.trigger ?? null,
+ startedAt: status.startedAt,
+ endedAt: status.endedAt,
+ durationMs: status.totalDurationMs,
+ paused: status.paused,
+ cost: status.cost,
+ error: status.error ? classifyExecutionError(new Error(status.error)) : null,
+ output: status.finalOutput,
+ blockOutputs: status.blockOutputs,
+ })
+ }
+
+ // No log row yet — a queued/just-started async run. Backfilled from the
+ // job queue via the deterministic id; authz already ran above.
+ const jobQueue = await getJobQueue()
+ const job = await jobQueue.getJob(`${WORKFLOW_EXECUTION_JOB_ID_PREFIX}${executionId}`)
+ const jobWorkflowId =
+ job?.metadata && typeof job.metadata === 'object'
+ ? (job.metadata as { workflowId?: string }).workflowId
+ : undefined
+ const mapped = job ? jobStatusToExecutionStatus(job.status) : null
+ if (!job || jobWorkflowId !== workflowId || !mapped) {
+ return v2Error('NOT_FOUND', 'Execution not found')
+ }
+
+ return v2Data({
+ executionId,
+ workflowId,
+ status: mapped,
+ trigger: 'api',
+ startedAt: null,
+ endedAt: null,
+ durationMs: null,
+ paused: null,
+ cost: null,
+ error:
+ mapped === 'failed' && job.error ? classifyExecutionError(new Error(job.error)) : null,
+ output: null,
+ blockOutputs: null,
+ })
+ } catch (error) {
+ if (error instanceof FunctionalOutputsUnavailableError) {
+ return v2Error('CONFLICT', FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE)
+ }
+ logger.error('Failed to fetch execution status', {
+ workflowId,
+ executionId,
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/workflows/[id]/export/route.ts b/apps/sim/app/api/v2/workflows/[id]/export/route.ts
new file mode 100644
index 00000000000..c4caa0ec7d8
--- /dev/null
+++ b/apps/sim/app/api/v2/workflows/[id]/export/route.ts
@@ -0,0 +1,102 @@
+import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
+import { createLogger } from '@sim/logger'
+import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import type { NextRequest } from 'next/server'
+import { v2ExportWorkflowContract } from '@/lib/api/contracts/v2/workflows'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { loadActiveFolderPathIndex } from '@/lib/folders/queries'
+import { buildWorkflowExportPayload } from '@/lib/workflows/operations/export-workflow'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { folderPathForId } from '@/app/api/v2/lib/folders'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2WorkflowExportAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+/**
+ * GET /api/v2/workflows/[id]/export
+ *
+ * Exports a workflow as a portable JSON envelope that
+ * `POST /api/v2/workflows/import` accepts verbatim. Payload assembly and the
+ * sanitization guarantees are documented on the shared
+ * {@link buildWorkflowExportPayload}; this route authenticates and renders the
+ * v2 envelope.
+ */
+export const GET = withRouteHandler(
+ async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'workflow-export')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2ExportWorkflowContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+
+ logger.info(`[${requestId}] Exporting workflow ${id}`, { userId })
+
+ const workflowData = await getActiveWorkflowRecord(id)
+ if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found')
+
+ // Mask an authorization failure as 404 so existence is not leaked.
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId)
+ if (access) return v2Error('NOT_FOUND', 'Workflow not found')
+
+ const payload = await buildWorkflowExportPayload(workflowData)
+ if (!payload) return v2Error('NOT_FOUND', 'Workflow state not found')
+ const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow')
+ const folderPath = folderPathForId(folderIndex, workflowData.folderId)
+
+ recordAudit({
+ workspaceId: workflowData.workspaceId,
+ actorId: userId,
+ action: AuditAction.WORKFLOW_EXPORTED,
+ resourceType: AuditResourceType.WORKFLOW,
+ resourceId: workflowData.id,
+ resourceName: workflowData.name,
+ description: `Exported workflow "${workflowData.name}" via the API`,
+ metadata: {
+ workspaceId: workflowData.workspaceId,
+ folderPath,
+ blocksCount: Object.keys(payload.state.blocks).length,
+ edgesCount: payload.state.edges.length,
+ },
+ request,
+ })
+
+ return v2Data(
+ {
+ ...payload,
+ workflow: {
+ id: payload.workflow.id,
+ name: payload.workflow.name,
+ description: payload.workflow.description,
+ workspaceId: payload.workflow.workspaceId,
+ folderPath,
+ },
+ },
+ { rateLimit }
+ )
+ } catch (error) {
+ logger.error(`[${requestId}] Workflow export error`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts
new file mode 100644
index 00000000000..8d1cea75788
--- /dev/null
+++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts
@@ -0,0 +1,125 @@
+import { createLogger } from '@sim/logger'
+import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { v1RollbackWorkflowBodySchema } from '@/lib/api/contracts/v1/workflows'
+import { v2RollbackWorkflowContract } from '@/lib/api/contracts/v2/workflows'
+import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { captureServerEvent } from '@/lib/posthog/server'
+import { performActivateVersion } from '@/lib/workflows/orchestration'
+import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils'
+import { checkRateLimit } from '@/app/api/v1/middleware'
+import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2WorkflowRollbackAPI')
+
+export const dynamic = 'force-dynamic'
+export const runtime = 'nodejs'
+export const maxDuration = 120
+
+export const POST = withRouteHandler(
+ async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
+ const requestId = generateRequestId()
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'workflow-rollback')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2RollbackWorkflowContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+
+ const rawBody = await parseOptionalJsonBody(request)
+ if (!rawBody.success) {
+ return rawBody.response.status === 413
+ ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large')
+ : v2Error('BAD_REQUEST', 'Request body must be valid JSON')
+ }
+ const body = v1RollbackWorkflowBodySchema.safeParse(rawBody.data ?? {})
+ if (!body.success) return v2ValidationError(body.error)
+
+ const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id)
+ if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found')
+ const { workflow, workspaceId } = target
+
+ if (!workflow.isDeployed) {
+ return v2Error('BAD_REQUEST', 'Workflow is not deployed')
+ }
+
+ await assertWorkflowMutable(id)
+
+ let targetVersion = body.data.version
+ if (targetVersion === undefined) {
+ const previous = await findPreviousDeploymentVersion(id)
+ if (!previous.ok) {
+ const message =
+ previous.reason === 'no_active_version'
+ ? 'Workflow has no active deployment to roll back from'
+ : 'No previous deployment version to roll back to'
+ return v2Error('BAD_REQUEST', message)
+ }
+ targetVersion = previous.version
+ }
+
+ logger.info(
+ `[${requestId}] Rolling back workflow ${id} to version ${targetVersion} via v2 API`,
+ { userId }
+ )
+
+ const result = await performActivateVersion({
+ workflowId: id,
+ version: targetVersion,
+ userId,
+ requestId,
+ })
+
+ if (!result.success) {
+ const code =
+ result.errorCode === 'not_found'
+ ? 'NOT_FOUND'
+ : result.errorCode === 'validation'
+ ? 'BAD_REQUEST'
+ : 'INTERNAL_ERROR'
+ return v2Error(code, result.error || 'Failed to roll back workflow')
+ }
+
+ captureServerEvent(
+ userId,
+ 'deployment_version_activated',
+ { workflow_id: id, workspace_id: workspaceId, version: targetVersion },
+ { groups: { workspace: workspaceId } }
+ )
+
+ return v2Data(
+ {
+ id,
+ isDeployed: true,
+ deployedAt: result.deployedAt?.toISOString() ?? null,
+ version: targetVersion,
+ warnings: result.warnings ?? [],
+ },
+ { rateLimit }
+ )
+ } catch (error) {
+ if (error instanceof WorkflowLockedError) {
+ return v2Error('LOCKED', error.message)
+ }
+ logger.error(`[${requestId}] Workflow rollback error`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/workflows/[id]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/route.test.ts
new file mode 100644
index 00000000000..33de9864022
--- /dev/null
+++ b/apps/sim/app/api/v2/workflows/[id]/route.test.ts
@@ -0,0 +1,348 @@
+/**
+ * @vitest-environment node
+ *
+ * Public v2 workflow update/delete: the 404 mask on an access failure (the
+ * caller never names a workspace, so a 403 would confirm the workflow exists),
+ * the 423 a workflow mutation lock produces, and the orchestration failure
+ * codes rendered in the v2 error envelope.
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockResolveWorkspaceAccess,
+ mockGetActiveWorkflowRecord,
+ mockPerformUpdateWorkflow,
+ mockPerformDeleteWorkflow,
+ mockAssertWorkflowMutable,
+ mockAssertFolderMutable,
+ mockLoadActiveFolderPathIndex,
+ WorkflowLockedErrorMock,
+ FolderLockedErrorMock,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceAccess: vi.fn(),
+ mockGetActiveWorkflowRecord: vi.fn(),
+ mockPerformUpdateWorkflow: vi.fn(),
+ mockPerformDeleteWorkflow: vi.fn(),
+ mockAssertWorkflowMutable: vi.fn(),
+ mockAssertFolderMutable: vi.fn(),
+ mockLoadActiveFolderPathIndex: vi.fn(),
+ WorkflowLockedErrorMock: class WorkflowLockedError extends Error {
+ status = 423
+ },
+ FolderLockedErrorMock: class FolderLockedError extends Error {
+ status = 423
+ },
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceAccess: mockResolveWorkspaceAccess,
+}))
+
+vi.mock('@/lib/workflows/orchestration', () => ({
+ performUpdateWorkflow: mockPerformUpdateWorkflow,
+ performDeleteWorkflow: mockPerformDeleteWorkflow,
+}))
+
+vi.mock('@sim/platform-authz/workflow', () => ({
+ getActiveWorkflowRecord: mockGetActiveWorkflowRecord,
+ assertWorkflowMutable: mockAssertWorkflowMutable,
+ assertFolderMutable: mockAssertFolderMutable,
+ WorkflowLockedError: WorkflowLockedErrorMock,
+ FolderLockedError: FolderLockedErrorMock,
+}))
+
+vi.mock('@/lib/folders/queries', () => ({
+ loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex,
+}))
+
+vi.mock('@/lib/workflows/input-format', () => ({
+ extractInputFieldsFromBlocks: vi.fn().mockReturnValue([]),
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+import { DELETE, PATCH } from '@/app/api/v2/workflows/[id]/route'
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+}
+
+const RATE_LIMIT_DENIED = {
+ allowed: false,
+ limit: 100,
+ remaining: 0,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+ retryAfterMs: 1000,
+}
+
+const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' }
+
+const WORKFLOW_RECORD = {
+ id: 'wf-1',
+ name: 'Support Agent',
+ description: 'Handles tickets',
+ folderId: null,
+ workspaceId: 'workspace-1',
+ isDeployed: true,
+ deployedAt: new Date('2024-01-03T00:00:00Z'),
+ runCount: 12,
+ lastRunAt: new Date('2024-01-04T00:00:00Z'),
+ locked: false,
+ forkSyncExcluded: false,
+ createdAt: new Date('2024-01-01T00:00:00Z'),
+ updatedAt: new Date('2024-01-02T00:00:00Z'),
+}
+
+const UPDATED = {
+ id: 'wf-1',
+ name: 'Support Agent v2',
+ description: 'Handles tickets',
+ workspaceId: 'workspace-1',
+ folderId: null,
+ sortOrder: 0,
+ locked: false,
+ forkSyncExcluded: false,
+ createdAt: new Date('2024-01-01T00:00:00Z'),
+ updatedAt: new Date('2024-01-05T00:00:00Z'),
+ archivedAt: null,
+}
+
+const routeContext = () => ({ params: Promise.resolve({ id: 'wf-1' }) })
+
+function callPatch(body: unknown) {
+ return PATCH(
+ new NextRequest('http://localhost:3000/api/v2/workflows/wf-1', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ }),
+ routeContext()
+ )
+}
+
+const callDelete = () =>
+ DELETE(
+ new NextRequest('http://localhost:3000/api/v2/workflows/wf-1', { method: 'DELETE' }),
+ routeContext()
+ )
+
+describe('PATCH /api/v2/workflows/[id]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD)
+ mockAssertWorkflowMutable.mockResolvedValue(undefined)
+ mockAssertFolderMutable.mockResolvedValue(undefined)
+ mockLoadActiveFolderPathIndex.mockResolvedValue({
+ rowById: new Map([['fld-1', { id: 'fld-1', name: 'Locked', parentId: null }]]),
+ pathById: new Map([['fld-1', '/Locked']]),
+ idByPath: new Map([['/Locked', 'fld-1']]),
+ })
+ mockPerformUpdateWorkflow.mockResolvedValue({ success: true, workflow: UPDATED })
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callPatch({ name: 'Support Agent v2' })
+
+ expect(res.status).toBe(404)
+ expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled()
+ })
+
+ it('400s when no field to change is supplied', async () => {
+ const res = await callPatch({})
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled()
+ })
+
+ it('masks an access-denied failure as 404 so existence is not leaked', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED)
+ const res = await callPatch({ name: 'Support Agent v2' })
+ expect(res.status).toBe(404)
+ expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callPatch({ name: 'Support Agent v2' })
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('404s when the workflow does not exist or is archived', async () => {
+ mockGetActiveWorkflowRecord.mockResolvedValue(null)
+ const res = await callPatch({ name: 'Support Agent v2' })
+ expect(res.status).toBe(404)
+ expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled()
+ })
+
+ it('423s the denial when the workflow is locked rather than failing with a 500', async () => {
+ mockAssertWorkflowMutable.mockRejectedValue(new WorkflowLockedErrorMock('Workflow is locked'))
+ const res = await callPatch({ name: 'Support Agent v2' })
+ expect(res.status).toBe(423)
+ expect((await res.json()).error.code).toBe('LOCKED')
+ expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled()
+ })
+
+ it('423s when the destination folder is locked', async () => {
+ mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked'))
+ const res = await callPatch({ folderPath: '/Locked' })
+ expect(res.status).toBe(423)
+ expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled()
+ })
+
+ it('404s a path outside the workspace without ever reading its lock state', async () => {
+ const res = await callPatch({ folderPath: '/Elsewhere' })
+
+ expect(res.status).toBe(404)
+ expect((await res.json()).error.code).toBe('NOT_FOUND')
+ expect(mockAssertFolderMutable).not.toHaveBeenCalled()
+ expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled()
+ })
+
+ it('resolves the canonical path against the workflow workspace before mutability', async () => {
+ await callPatch({ folderPath: '/Locked' })
+
+ expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith(
+ 'workspace-1',
+ 'workflow',
+ expect.any(Object)
+ )
+ expect(mockAssertFolderMutable).toHaveBeenCalledWith('fld-1')
+ })
+
+ it('skips the containment check on a rename that does not move the workflow', async () => {
+ await callPatch({ name: 'Support Agent v2' })
+ expect(mockAssertFolderMutable).not.toHaveBeenCalled()
+ })
+
+ it('409s when the target name is taken in the destination folder', async () => {
+ mockPerformUpdateWorkflow.mockResolvedValue({
+ success: false,
+ error: 'A workflow named "Support Agent v2" already exists in this folder',
+ errorCode: 'conflict',
+ })
+ const res = await callPatch({ name: 'Support Agent v2' })
+ expect(res.status).toBe(409)
+ expect((await res.json()).error.code).toBe('CONFLICT')
+ })
+
+ it('updates the workflow and carries the untouched deployment counters through', async () => {
+ const res = await callPatch({ name: 'Support Agent v2' })
+ const body = await res.json()
+
+ expect(res.status).toBe(200)
+ expect(body).toEqual({
+ data: {
+ id: 'wf-1',
+ name: 'Support Agent v2',
+ description: 'Handles tickets',
+ folderPath: '/',
+ workspaceId: 'workspace-1',
+ isDeployed: true,
+ deployedAt: '2024-01-03T00:00:00.000Z',
+ runCount: 12,
+ lastRunAt: '2024-01-04T00:00:00.000Z',
+ createdAt: '2024-01-01T00:00:00.000Z',
+ updatedAt: '2024-01-05T00:00:00.000Z',
+ },
+ })
+ expect(mockPerformUpdateWorkflow).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workflowId: 'wf-1',
+ userId: 'user-1',
+ workspaceId: 'workspace-1',
+ currentName: 'Support Agent',
+ currentFolderId: null,
+ name: 'Support Agent v2',
+ })
+ )
+ })
+})
+
+describe('DELETE /api/v2/workflows/[id]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD)
+ mockAssertWorkflowMutable.mockResolvedValue(undefined)
+ mockPerformDeleteWorkflow.mockResolvedValue({ success: true })
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callDelete()
+
+ expect(res.status).toBe(404)
+ expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled()
+ })
+
+ it('masks an access-denied failure as 404 so existence is not leaked', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED)
+ const res = await callDelete()
+ expect(res.status).toBe(404)
+ expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callDelete()
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('404s when the workflow does not exist or is already archived', async () => {
+ mockGetActiveWorkflowRecord.mockResolvedValue(null)
+ const res = await callDelete()
+ expect(res.status).toBe(404)
+ expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled()
+ })
+
+ it('423s the denial when the workflow is locked rather than failing with a 500', async () => {
+ mockAssertWorkflowMutable.mockRejectedValue(new WorkflowLockedErrorMock('Workflow is locked'))
+ const res = await callDelete()
+ expect(res.status).toBe(423)
+ expect((await res.json()).error.code).toBe('LOCKED')
+ expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled()
+ })
+
+ it('400s when it is the last workflow in the workspace', async () => {
+ mockPerformDeleteWorkflow.mockResolvedValue({
+ success: false,
+ error: 'Cannot delete the only workflow in the workspace',
+ errorCode: 'validation',
+ })
+ const res = await callDelete()
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.message).toContain('only workflow')
+ })
+
+ it('archives the workflow and acknowledges the delete', async () => {
+ const res = await callDelete()
+ expect(res.status).toBe(200)
+ expect(await res.json()).toEqual({ data: { id: 'wf-1', deleted: true } })
+ expect(mockPerformDeleteWorkflow).toHaveBeenCalledWith(
+ expect.objectContaining({ workflowId: 'wf-1', userId: 'user-1' })
+ )
+ })
+})
diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts
new file mode 100644
index 00000000000..f184af03c30
--- /dev/null
+++ b/apps/sim/app/api/v2/workflows/[id]/route.ts
@@ -0,0 +1,260 @@
+import { db } from '@sim/db'
+import { workflowBlocks } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import {
+ assertFolderMutable,
+ assertWorkflowMutable,
+ FolderLockedError,
+ getActiveWorkflowRecord,
+ WorkflowLockedError,
+} from '@sim/platform-authz/workflow'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { eq } from 'drizzle-orm'
+import type { NextRequest } from 'next/server'
+import {
+ type V2WorkflowDetail,
+ type V2WorkflowListItem,
+ v2DeleteWorkflowContract,
+ v2GetWorkflowContract,
+ v2UpdateWorkflowContract,
+} from '@/lib/api/contracts/v2/workflows'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { loadActiveFolderPathIndex } from '@/lib/folders/queries'
+import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
+import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2Data,
+ v2Error,
+ v2ErrorForOrchestration,
+ v2RateLimitError,
+ v2ValidationError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2WorkflowDetailAPI')
+
+export const revalidate = 0
+
+interface RouteContext {
+ params: Promise<{ id: string }>
+}
+
+export const GET = withRouteHandler(
+ async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'workflow-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2GetWorkflowContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+
+ const workflowData = await getActiveWorkflowRecord(id)
+ if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found')
+
+ // Mask an authorization failure as 404 so existence is not leaked.
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId)
+ if (access) return v2Error('NOT_FOUND', 'Workflow not found')
+
+ const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow')
+
+ const blockRows = await db
+ .select({
+ id: workflowBlocks.id,
+ type: workflowBlocks.type,
+ subBlocks: workflowBlocks.subBlocks,
+ })
+ .from(workflowBlocks)
+ .where(eq(workflowBlocks.workflowId, id))
+
+ const blocksRecord = Object.fromEntries(
+ blockRows.map((block) => [block.id, { type: block.type, subBlocks: block.subBlocks }])
+ )
+ const inputs = extractInputFieldsFromBlocks(blocksRecord)
+
+ const detail: V2WorkflowDetail = {
+ id: workflowData.id,
+ name: workflowData.name,
+ description: workflowData.description,
+ folderPath: folderPathForId(folderIndex, workflowData.folderId),
+ workspaceId: workflowData.workspaceId,
+ isDeployed: workflowData.isDeployed,
+ deployedAt: workflowData.deployedAt?.toISOString() ?? null,
+ runCount: workflowData.runCount,
+ lastRunAt: workflowData.lastRunAt?.toISOString() ?? null,
+ variables: (workflowData.variables as Record | null) ?? {},
+ inputs,
+ createdAt: workflowData.createdAt.toISOString(),
+ updatedAt: workflowData.updatedAt.toISOString(),
+ }
+
+ return v2Data(detail, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Workflow details fetch error`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
+
+/** PATCH /api/v2/workflows/[id] — Rename, re-describe, or move a workflow. */
+export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'workflow-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2UpdateWorkflowContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+ const { name, description, folderPath } = parsed.data.body
+
+ const workflowData = await getActiveWorkflowRecord(id)
+ if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found')
+
+ // Mask an authorization failure as 404 so existence is not leaked.
+ const access = await resolveWorkspaceAccess(
+ rateLimit,
+ userId,
+ workflowData.workspaceId,
+ 'write'
+ )
+ if (access) return v2Error('NOT_FOUND', 'Workflow not found')
+
+ const resolution =
+ folderPath === undefined
+ ? undefined
+ : await resolveFolderPathIdentity({
+ workspaceId: workflowData.workspaceId,
+ resourceType: 'workflow',
+ path: folderPath,
+ })
+ if (resolution && !resolution.found) {
+ return v2Error('NOT_FOUND', 'Folder not found')
+ }
+
+ const folderId = resolution?.folderId
+ await assertWorkflowMutable(id)
+ if (folderId !== undefined) await assertFolderMutable(folderId)
+
+ const result = await performUpdateWorkflow({
+ workflowId: id,
+ userId,
+ workspaceId: workflowData.workspaceId,
+ currentName: workflowData.name,
+ currentFolderId: workflowData.folderId,
+ name,
+ description,
+ folderId,
+ requestId,
+ })
+
+ if (!result.success || !result.workflow) {
+ return v2ErrorForOrchestration(result.errorCode, result.error ?? 'Failed to update workflow')
+ }
+
+ const updated = result.workflow
+ const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow')
+ /**
+ * Deployment and run counters are untouched by a metadata update, so they
+ * come from the record read above rather than a second query.
+ */
+ const item: V2WorkflowListItem = {
+ id: updated.id,
+ name: updated.name,
+ description: updated.description,
+ folderPath: folderPathForId(folderIndex, updated.folderId),
+ workspaceId: updated.workspaceId ?? workflowData.workspaceId,
+ isDeployed: workflowData.isDeployed,
+ deployedAt: workflowData.deployedAt?.toISOString() ?? null,
+ runCount: workflowData.runCount,
+ lastRunAt: workflowData.lastRunAt?.toISOString() ?? null,
+ createdAt: updated.createdAt.toISOString(),
+ updatedAt: updated.updatedAt.toISOString(),
+ }
+
+ return v2Data(item, { rateLimit })
+ } catch (error) {
+ if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) {
+ return v2Error('LOCKED', error.message)
+ }
+
+ logger.error(`[${requestId}] Workflow update error`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'workflow-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2DeleteWorkflowContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+
+ const workflowData = await getActiveWorkflowRecord(id)
+ if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found')
+
+ // Mask an authorization failure as 404 so existence is not leaked.
+ const access = await resolveWorkspaceAccess(
+ rateLimit,
+ userId,
+ workflowData.workspaceId,
+ 'write'
+ )
+ if (access) return v2Error('NOT_FOUND', 'Workflow not found')
+
+ await assertWorkflowMutable(id)
+
+ const result = await performDeleteWorkflow({ workflowId: id, userId, requestId })
+ if (!result.success) {
+ return v2ErrorForOrchestration(result.errorCode, result.error ?? 'Failed to delete workflow')
+ }
+
+ return v2Data({ id, deleted: true as const }, { rateLimit })
+ } catch (error) {
+ if (error instanceof WorkflowLockedError) return v2Error('LOCKED', error.message)
+
+ logger.error(`[${requestId}] Workflow delete error`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts
new file mode 100644
index 00000000000..72e3811cb6e
--- /dev/null
+++ b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts
@@ -0,0 +1,154 @@
+/**
+ * @vitest-environment node
+ *
+ * Public v2 deployment-version detail: the 404 mask on an access failure, the
+ * coerced numeric version param, and the pinned workflow state it serves.
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockResolveWorkspaceAccess,
+ mockGetActiveWorkflowRecord,
+ mockGetWorkflowDeploymentVersion,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceAccess: vi.fn(),
+ mockGetActiveWorkflowRecord: vi.fn(),
+ mockGetWorkflowDeploymentVersion: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceAccess: mockResolveWorkspaceAccess,
+}))
+
+vi.mock('@sim/platform-authz/workflow', () => ({
+ getActiveWorkflowRecord: mockGetActiveWorkflowRecord,
+}))
+
+vi.mock('@/lib/workflows/persistence/utils', () => ({
+ getWorkflowDeploymentVersion: mockGetWorkflowDeploymentVersion,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+import { GET } from '@/app/api/v2/workflows/[id]/versions/[version]/route'
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+}
+
+const RATE_LIMIT_DENIED = {
+ allowed: false,
+ limit: 100,
+ remaining: 0,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+ retryAfterMs: 1000,
+}
+
+const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' }
+
+const WORKFLOW_RECORD = { id: 'wf-1', name: 'Support Agent', workspaceId: 'workspace-1' }
+
+const DEPLOYED_STATE = { blocks: {}, edges: [], loops: {}, parallels: {} }
+
+const VERSION_ROW = {
+ id: 'dv-3',
+ version: 3,
+ name: 'Escalation branch',
+ description: null,
+ isActive: true,
+ createdAt: new Date('2024-01-03T00:00:00Z'),
+ state: DEPLOYED_STATE,
+}
+
+const routeContext = (version = '3') => ({ params: Promise.resolve({ id: 'wf-1', version }) })
+const callGet = (version = '3') =>
+ GET(
+ new NextRequest(`http://localhost:3000/api/v2/workflows/wf-1/versions/${version}`),
+ routeContext(version)
+ )
+
+describe('GET /api/v2/workflows/[id]/versions/[version]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD)
+ mockGetWorkflowDeploymentVersion.mockResolvedValue(VERSION_ROW)
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callGet()
+
+ expect(res.status).toBe(404)
+ expect(mockGetWorkflowDeploymentVersion).not.toHaveBeenCalled()
+ })
+
+ it('400s on a non-numeric version', async () => {
+ const res = await callGet('latest')
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(mockGetWorkflowDeploymentVersion).not.toHaveBeenCalled()
+ })
+
+ it('masks an access-denied failure as 404 so existence is not leaked', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED)
+ const res = await callGet()
+ expect(res.status).toBe(404)
+ expect(mockGetWorkflowDeploymentVersion).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callGet()
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('404s when the workflow does not exist or is archived', async () => {
+ mockGetActiveWorkflowRecord.mockResolvedValue(null)
+ const res = await callGet()
+ expect(res.status).toBe(404)
+ expect(mockGetWorkflowDeploymentVersion).not.toHaveBeenCalled()
+ })
+
+ it('404s when the version does not exist on this workflow', async () => {
+ mockGetWorkflowDeploymentVersion.mockResolvedValue(null)
+ const res = await callGet()
+ expect(res.status).toBe(404)
+ expect((await res.json()).error.message).toBe('Deployment version not found')
+ })
+
+ it('returns the version with the workflow state it pins', async () => {
+ const res = await callGet()
+ const body = await res.json()
+
+ expect(res.status).toBe(200)
+ expect(body).toEqual({
+ data: {
+ id: 'dv-3',
+ version: 3,
+ name: 'Escalation branch',
+ description: null,
+ isActive: true,
+ createdAt: '2024-01-03T00:00:00.000Z',
+ state: DEPLOYED_STATE,
+ },
+ })
+ expect(mockGetWorkflowDeploymentVersion).toHaveBeenCalledWith('wf-1', 3)
+ })
+})
diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts
new file mode 100644
index 00000000000..d8096bf5ea5
--- /dev/null
+++ b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts
@@ -0,0 +1,74 @@
+import { createLogger } from '@sim/logger'
+import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import type { NextRequest } from 'next/server'
+import {
+ type V2WorkflowVersionDetail,
+ v2GetWorkflowVersionContract,
+} from '@/lib/api/contracts/v2/workflows'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getWorkflowDeploymentVersion } from '@/lib/workflows/persistence/utils'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2WorkflowVersionDetailAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+/**
+ * GET /api/v2/workflows/[id]/versions/[version] — Fetch one deployment version
+ * and the workflow state it pins.
+ */
+export const GET = withRouteHandler(
+ async (request: NextRequest, context: { params: Promise<{ id: string; version: string }> }) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'workflow-version-detail')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2GetWorkflowVersionContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id, version } = parsed.data.params
+
+ const workflowData = await getActiveWorkflowRecord(id)
+ if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found')
+
+ // Mask an authorization failure as 404 so existence is not leaked.
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId)
+ if (access) return v2Error('NOT_FOUND', 'Workflow not found')
+
+ const row = await getWorkflowDeploymentVersion(id, version)
+ if (!row?.state) return v2Error('NOT_FOUND', 'Deployment version not found')
+
+ const detail: V2WorkflowVersionDetail = {
+ id: row.id,
+ version: row.version,
+ name: row.name,
+ description: row.description,
+ isActive: row.isActive,
+ createdAt: row.createdAt.toISOString(),
+ state: row.state as V2WorkflowVersionDetail['state'],
+ }
+
+ return v2Data(detail, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Workflow version fetch error`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts
new file mode 100644
index 00000000000..53025c2d07d
--- /dev/null
+++ b/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts
@@ -0,0 +1,221 @@
+/**
+ * @vitest-environment node
+ *
+ * Public v2 deployment-version listing: the 404 mask on an access failure, the
+ * public projection (no raw `createdBy` user id), and the version-keyed cursor.
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockResolveWorkspaceAccess,
+ mockGetActiveWorkflowRecord,
+ mockListWorkflowVersions,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceAccess: vi.fn(),
+ mockGetActiveWorkflowRecord: vi.fn(),
+ mockListWorkflowVersions: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceAccess: mockResolveWorkspaceAccess,
+}))
+
+vi.mock('@sim/platform-authz/workflow', () => ({
+ getActiveWorkflowRecord: mockGetActiveWorkflowRecord,
+}))
+
+vi.mock('@/lib/workflows/persistence/utils', () => ({
+ listWorkflowVersions: mockListWorkflowVersions,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+import { GET } from '@/app/api/v2/workflows/[id]/versions/route'
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+}
+
+const RATE_LIMIT_DENIED = {
+ allowed: false,
+ limit: 100,
+ remaining: 0,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+ retryAfterMs: 1000,
+}
+
+const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' }
+
+const WORKFLOW_RECORD = { id: 'wf-1', name: 'Support Agent', workspaceId: 'workspace-1' }
+
+function buildVersion(version: number, overrides: Record = {}) {
+ return {
+ id: `dv-${version}`,
+ version,
+ name: null,
+ description: null,
+ isActive: false,
+ createdAt: new Date(`2024-01-0${version}T00:00:00Z`),
+ createdBy: 'user-9',
+ deployedByName: 'Ada Lovelace',
+ latestOperationStatus: null,
+ ...overrides,
+ }
+}
+
+const ALL_VERSIONS = [
+ buildVersion(3, { isActive: true, name: 'Escalation branch', latestOperationStatus: 'active' }),
+ buildVersion(2),
+ buildVersion(1),
+]
+
+const routeContext = () => ({ params: Promise.resolve({ id: 'wf-1' }) })
+const callGet = (query = '') =>
+ GET(
+ new NextRequest(`http://localhost:3000/api/v2/workflows/wf-1/versions${query}`),
+ routeContext()
+ )
+
+describe('GET /api/v2/workflows/[id]/versions', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD)
+ /**
+ * Stands in for the keyset query the helper now runs, so the route's
+ * has-more probe and cursor round-trip are exercised against realistic
+ * `limit`/`afterVersion` behavior rather than a fixed array.
+ */
+ mockListWorkflowVersions.mockImplementation(
+ async (_workflowId: string, options: { limit?: number; afterVersion?: number } = {}) => {
+ let versions = ALL_VERSIONS
+ if (options.afterVersion !== undefined) {
+ versions = versions.filter((row) => row.version < options.afterVersion!)
+ }
+ if (options.limit !== undefined) versions = versions.slice(0, options.limit)
+ return { versions }
+ }
+ )
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callGet()
+
+ expect(res.status).toBe(404)
+ expect(mockListWorkflowVersions).not.toHaveBeenCalled()
+ })
+
+ it('400s on an out-of-range limit', async () => {
+ const res = await callGet('?limit=0')
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(mockListWorkflowVersions).not.toHaveBeenCalled()
+ })
+
+ it('masks an access-denied failure as 404 so existence is not leaked', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED)
+ const res = await callGet()
+ expect(res.status).toBe(404)
+ expect(mockListWorkflowVersions).not.toHaveBeenCalled()
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callGet()
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('404s when the workflow does not exist or is archived', async () => {
+ mockGetActiveWorkflowRecord.mockResolvedValue(null)
+ const res = await callGet()
+ expect(res.status).toBe(404)
+ expect(mockListWorkflowVersions).not.toHaveBeenCalled()
+ })
+
+ it('returns the public version shape newest-first, without the raw creator id', async () => {
+ const res = await callGet()
+ const body = await res.json()
+
+ expect(res.status).toBe(200)
+ expect(body.nextCursor).toBeNull()
+ expect(body.data).toHaveLength(3)
+ expect(body.data[0]).toEqual({
+ id: 'dv-3',
+ version: 3,
+ name: 'Escalation branch',
+ description: null,
+ isActive: true,
+ createdAt: '2024-01-03T00:00:00.000Z',
+ deployedBy: 'Ada Lovelace',
+ latestOperationStatus: 'active',
+ })
+ expect(body.data[0]).not.toHaveProperty('createdBy')
+ // Paging is pushed into the helper — the route never reads the full set.
+ expect(mockListWorkflowVersions).toHaveBeenCalledWith('wf-1', {
+ limit: 51,
+ afterVersion: undefined,
+ })
+ })
+
+ it('bounds the read to one page plus the has-more probe', async () => {
+ await callGet('?limit=2')
+ expect(mockListWorkflowVersions).toHaveBeenCalledWith('wf-1', {
+ limit: 3,
+ afterVersion: undefined,
+ })
+ })
+
+ it('pushes the cursor down to the helper as a keyset bound', async () => {
+ const cursor = Buffer.from(JSON.stringify({ version: 3 })).toString('base64')
+ await callGet(`?limit=2&cursor=${encodeURIComponent(cursor)}`)
+ expect(mockListWorkflowVersions).toHaveBeenCalledWith('wf-1', { limit: 3, afterVersion: 3 })
+ })
+
+ it('400s a structurally invalid cursor instead of silently truncating the list', async () => {
+ // Decodes to valid JSON with no numeric `version` — the shape that would
+ // otherwise filter every row out and report a clean end-of-list.
+ const bogus = Buffer.from(JSON.stringify({ offset: 2 })).toString('base64')
+ const res = await callGet(`?cursor=${encodeURIComponent(bogus)}`)
+
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(mockListWorkflowVersions).not.toHaveBeenCalled()
+ })
+
+ it('400s a cursor that is not decodable at all', async () => {
+ const res = await callGet('?cursor=not-a-cursor')
+ expect(res.status).toBe(400)
+ expect(mockListWorkflowVersions).not.toHaveBeenCalled()
+ })
+
+ it('pages with a version-keyed cursor', async () => {
+ const first = await callGet('?limit=2')
+ const firstBody = await first.json()
+
+ expect(firstBody.data.map((v: { version: number }) => v.version)).toEqual([3, 2])
+ expect(firstBody.nextCursor).toEqual(expect.any(String))
+
+ const second = await callGet(`?limit=2&cursor=${encodeURIComponent(firstBody.nextCursor)}`)
+ const secondBody = await second.json()
+
+ expect(secondBody.data.map((v: { version: number }) => v.version)).toEqual([1])
+ expect(secondBody.nextCursor).toBeNull()
+ })
+})
diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts
new file mode 100644
index 00000000000..82c26667795
--- /dev/null
+++ b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts
@@ -0,0 +1,111 @@
+import { createLogger } from '@sim/logger'
+import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import type { NextRequest } from 'next/server'
+import {
+ type V2WorkflowVersion,
+ v2ListWorkflowVersionsContract,
+} from '@/lib/api/contracts/v2/workflows'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { listWorkflowVersions } from '@/lib/workflows/persistence/utils'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ decodeCursor,
+ encodeCursor,
+ v2CursorList,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2WorkflowVersionsAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+/** Keyset cursor over the dense, strictly-descending version number. */
+interface WorkflowVersionCursor {
+ version: number
+}
+
+/**
+ * GET /api/v2/workflows/[id]/versions — List a workflow's deployment versions,
+ * newest first. These are the versions `POST /api/v2/workflows/[id]/rollback`
+ * accepts, so a caller no longer has to guess a version number.
+ */
+export const GET = withRouteHandler(
+ async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'workflow-versions')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(v2ListWorkflowVersionsContract, request, context, {
+ validationErrorResponse: v2ValidationError,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+ const { limit, cursor } = parsed.data.query
+
+ const workflowData = await getActiveWorkflowRecord(id)
+ if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found')
+
+ // Mask an authorization failure as 404 so existence is not leaked.
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId)
+ if (access) return v2Error('NOT_FOUND', 'Workflow not found')
+
+ /**
+ * A cursor that decodes to anything other than a version number is
+ * rejected rather than ignored: comparing every row against a missing
+ * `version` yields an empty page with `nextCursor: null`, which reads to
+ * the caller as a clean end-of-list while versions are still pending.
+ */
+ const after = cursor ? decodeCursor(cursor) : null
+ if (cursor && (!after || !Number.isInteger(after.version) || after.version < 1)) {
+ return v2Error('BAD_REQUEST', 'Invalid cursor')
+ }
+
+ // One extra row is the has-more probe, matching the other v2 cursor lists.
+ const { versions: rows } = await listWorkflowVersions(id, {
+ limit: limit + 1,
+ afterVersion: after?.version,
+ })
+
+ const hasMore = rows.length > limit
+ const page = rows.slice(0, limit)
+
+ const data: V2WorkflowVersion[] = page.map((row) => ({
+ id: row.id,
+ version: row.version,
+ name: row.name,
+ description: row.description,
+ isActive: row.isActive,
+ createdAt: row.createdAt.toISOString(),
+ deployedBy: row.deployedByName,
+ // The shared helper widens the operation-status pg enum to `string`.
+ latestOperationStatus:
+ row.latestOperationStatus as V2WorkflowVersion['latestOperationStatus'],
+ }))
+
+ const nextCursor =
+ hasMore && data.length > 0 ? encodeCursor({ version: data[data.length - 1].version }) : null
+
+ return v2CursorList(data, nextCursor, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Workflow versions fetch error`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+ }
+)
diff --git a/apps/sim/app/api/v2/workflows/folders/route.test.ts b/apps/sim/app/api/v2/workflows/folders/route.test.ts
new file mode 100644
index 00000000000..696286333ed
--- /dev/null
+++ b/apps/sim/app/api/v2/workflows/folders/route.test.ts
@@ -0,0 +1,216 @@
+/**
+ * @vitest-environment node
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockResolveWorkspaceAccess,
+ mockLoadActiveFolderPathIndex,
+ mockListActiveFolderRows,
+ mockCreateFolderAtPath,
+ mockRelocateFolderByPath,
+ mockDeleteFolderByPath,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceAccess: vi.fn(),
+ mockLoadActiveFolderPathIndex: vi.fn(),
+ mockListActiveFolderRows: vi.fn(),
+ mockCreateFolderAtPath: vi.fn(),
+ mockRelocateFolderByPath: vi.fn(),
+ mockDeleteFolderByPath: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceAccess: mockResolveWorkspaceAccess,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+vi.mock('@/lib/folders/queries', () => ({
+ loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex,
+ listActiveFolderRows: mockListActiveFolderRows,
+}))
+
+vi.mock('@/lib/folders/orchestration', () => ({
+ createFolderAtPath: mockCreateFolderAtPath,
+ relocateFolderByPath: mockRelocateFolderByPath,
+ deleteFolderByPath: mockDeleteFolderByPath,
+}))
+
+import { DELETE, GET, PATCH, POST } from '@/app/api/v2/workflows/folders/route'
+
+const WORKSPACE_ID = 'workspace-1'
+const FOLDER_ID = 'internal-folder-id'
+const RATE_LIMIT = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+}
+
+const folder = {
+ id: FOLDER_ID,
+ resourceType: 'workflow' as const,
+ name: 'Reports',
+ userId: 'user-1',
+ workspaceId: WORKSPACE_ID,
+ parentId: null,
+ sortOrder: 0,
+ locked: false,
+ createdAt: new Date('2024-01-01T00:00:00Z'),
+ updatedAt: new Date('2024-01-02T00:00:00Z'),
+ deletedAt: null,
+}
+
+function pathIndex(path = '/Reports') {
+ return {
+ rowById: new Map([[FOLDER_ID, folder]]),
+ pathById: new Map([[FOLDER_ID, path]]),
+ idByPath: new Map([[path, FOLDER_ID]]),
+ }
+}
+
+function request(method: string, path: string, body?: Record) {
+ return new NextRequest(`http://localhost:3000${path}`, {
+ method,
+ headers: body ? { 'Content-Type': 'application/json' } : undefined,
+ body: body ? JSON.stringify(body) : undefined,
+ })
+}
+
+describe('/api/v2/workflows/folders', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockLoadActiveFolderPathIndex.mockResolvedValue(pathIndex())
+ mockListActiveFolderRows.mockResolvedValue([folder])
+ mockCreateFolderAtPath.mockResolvedValue({
+ success: true,
+ folder,
+ path: '/Reports',
+ })
+ mockRelocateFolderByPath.mockResolvedValue({
+ success: true,
+ folder,
+ path: '/Reports',
+ })
+ mockDeleteFolderByPath.mockResolvedValue({
+ success: true,
+ path: '/Reports',
+ deletedItems: { folders: 1, workflows: 2 },
+ })
+ })
+
+ it('lists only root children when parentPath is root and never exposes database ids', async () => {
+ const response = await GET(
+ request('GET', `/api/v2/workflows/folders?workspaceId=${WORKSPACE_ID}&parentPath=%2F`)
+ )
+ const body = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(mockListActiveFolderRows).toHaveBeenCalledWith(WORKSPACE_ID, 'workflow', {
+ parentId: null,
+ search: undefined,
+ sortBy: 'name',
+ sortOrder: 'asc',
+ })
+ expect(body.data).toEqual([
+ {
+ name: 'Reports',
+ path: '/Reports',
+ parentPath: '/',
+ locked: false,
+ createdAt: '2024-01-01T00:00:00.000Z',
+ updatedAt: '2024-01-02T00:00:00.000Z',
+ },
+ ])
+ })
+
+ it('omits the parent filter to list folders from the whole tree', async () => {
+ await GET(request('GET', `/api/v2/workflows/folders?workspaceId=${WORKSPACE_ID}`))
+
+ expect(mockListActiveFolderRows).toHaveBeenCalledWith(WORKSPACE_ID, 'workflow', {
+ parentId: undefined,
+ search: undefined,
+ sortBy: 'name',
+ sortOrder: 'asc',
+ })
+ })
+
+ it('creates a folder from a canonical path and rejects internal ids', async () => {
+ const created = await POST(
+ request('POST', '/api/v2/workflows/folders', {
+ workspaceId: WORKSPACE_ID,
+ path: '/Reports',
+ })
+ )
+
+ expect(created.status).toBe(201)
+ expect(mockCreateFolderAtPath).toHaveBeenCalledWith({
+ resourceType: 'workflow',
+ workspaceId: WORKSPACE_ID,
+ userId: 'user-1',
+ path: '/Reports',
+ })
+
+ const rejected = await POST(
+ request('POST', '/api/v2/workflows/folders', {
+ workspaceId: WORKSPACE_ID,
+ path: '/Reports',
+ folderId: FOLDER_ID,
+ })
+ )
+ expect(rejected.status).toBe(400)
+ })
+
+ it('relocates one folder by source and destination paths', async () => {
+ mockLoadActiveFolderPathIndex.mockResolvedValue(pathIndex('/Archive'))
+ const response = await PATCH(
+ request('PATCH', '/api/v2/workflows/folders', {
+ workspaceId: WORKSPACE_ID,
+ path: '/Reports',
+ destinationPath: '/Archive',
+ })
+ )
+
+ expect(response.status).toBe(200)
+ expect(mockRelocateFolderByPath).toHaveBeenCalledWith({
+ resourceType: 'workflow',
+ workspaceId: WORKSPACE_ID,
+ userId: 'user-1',
+ path: '/Reports',
+ destinationPath: '/Archive',
+ })
+ })
+
+ it('requires an explicit recursive delete choice', async () => {
+ const missing = await DELETE(
+ request('DELETE', `/api/v2/workflows/folders?workspaceId=${WORKSPACE_ID}&path=%2FReports`)
+ )
+ expect(missing.status).toBe(400)
+ expect(mockDeleteFolderByPath).not.toHaveBeenCalled()
+
+ const deleted = await DELETE(
+ request(
+ 'DELETE',
+ `/api/v2/workflows/folders?workspaceId=${WORKSPACE_ID}&path=%2FReports&recursive=true`
+ )
+ )
+ expect(deleted.status).toBe(200)
+ expect(await deleted.json()).toEqual({
+ data: {
+ path: '/Reports',
+ deleted: true,
+ deletedItems: { folders: 1, workflows: 2 },
+ },
+ })
+ })
+})
diff --git a/apps/sim/app/api/v2/workflows/folders/route.ts b/apps/sim/app/api/v2/workflows/folders/route.ts
new file mode 100644
index 00000000000..bb791ec6dce
--- /dev/null
+++ b/apps/sim/app/api/v2/workflows/folders/route.ts
@@ -0,0 +1,186 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import {
+ v2CreateWorkflowFolderContract,
+ v2DeleteWorkflowFolderContract,
+ v2ListWorkflowFoldersContract,
+ v2RelocateWorkflowFolderContract,
+} from '@/lib/api/contracts/v2/workflows'
+import { parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ createFolderAtPath,
+ deleteFolderByPath,
+ relocateFolderByPath,
+} from '@/lib/folders/orchestration'
+import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import {
+ resolveFolderPathId,
+ toV2PathFolder,
+ v2FolderPathMutationError,
+} from '@/app/api/v2/lib/folders'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ v2CursorList,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2WorkflowFoldersAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+export const GET = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+ try {
+ const rateLimit = await checkRateLimit(request, 'workflows')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2ListWorkflowFoldersContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+ const { workspaceId, parentPath, search, sortBy, sortOrder } = parsed.data.query
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const index = await loadActiveFolderPathIndex(workspaceId, 'workflow')
+ const parentId = parentPath === undefined ? undefined : resolveFolderPathId(index, parentPath)
+ if (parentPath !== undefined && parentId === undefined) {
+ return v2Error('NOT_FOUND', 'Folder not found')
+ }
+ const rows = await listActiveFolderRows(workspaceId, 'workflow', {
+ parentId,
+ search,
+ sortBy,
+ sortOrder,
+ })
+ return v2CursorList(
+ rows.map((row) => toV2PathFolder(row, index, true)),
+ null,
+ { rateLimit }
+ )
+ } catch (error) {
+ logger.error(`[${requestId}] Error listing workflow folders`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const rateLimit = await checkRateLimit(request, 'workflows')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(
+ v2CreateWorkflowFolderContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+ const { workspaceId, path } = parsed.data.body
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const result = await createFolderAtPath({ resourceType: 'workflow', workspaceId, userId, path })
+ if (!result.success || !result.folder || !result.path) {
+ return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder')
+ }
+ const index = await loadActiveFolderPathIndex(workspaceId, 'workflow')
+ return v2Data({ folder: toV2PathFolder(result.folder, index, true) }, { rateLimit, status: 201 })
+})
+
+export const PATCH = withRouteHandler(async (request: NextRequest) => {
+ const rateLimit = await checkRateLimit(request, 'workflows')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(
+ v2RelocateWorkflowFolderContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+ const { workspaceId, path, destinationPath } = parsed.data.body
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const result = await relocateFolderByPath({
+ resourceType: 'workflow',
+ workspaceId,
+ userId,
+ path,
+ destinationPath,
+ })
+ if (!result.success || !result.folder || !result.path) {
+ return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder')
+ }
+ const index = await loadActiveFolderPathIndex(workspaceId, 'workflow')
+ return v2Data({ folder: toV2PathFolder(result.folder, index, true) }, { rateLimit })
+})
+
+export const DELETE = withRouteHandler(async (request: NextRequest) => {
+ const rateLimit = await checkRateLimit(request, 'workflows')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+ const userId = rateLimit.userId!
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+ const parsed = await parseRequest(
+ v2DeleteWorkflowFolderContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+ const { workspaceId, path, recursive } = parsed.data.query
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const result = await deleteFolderByPath({
+ resourceType: 'workflow',
+ workspaceId,
+ userId,
+ path,
+ recursive,
+ })
+ if (!result.success || !result.deletedItems) {
+ return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder')
+ }
+ return v2Data(
+ {
+ path,
+ deleted: true as const,
+ deletedItems: {
+ folders: result.deletedItems.folders,
+ workflows: result.deletedItems.workflows ?? 0,
+ },
+ },
+ { rateLimit }
+ )
+})
diff --git a/apps/sim/app/api/v2/workflows/import/route.ts b/apps/sim/app/api/v2/workflows/import/route.ts
new file mode 100644
index 00000000000..e70da75d915
--- /dev/null
+++ b/apps/sim/app/api/v2/workflows/import/route.ts
@@ -0,0 +1,120 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import type { NextRequest } from 'next/server'
+import { v2ImportWorkflowContract } from '@/lib/api/contracts/v2/workflows'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ importWorkflowIntoWorkspace,
+ MAX_IMPORT_BODY_BYTES,
+} from '@/lib/workflows/operations/import-workflow'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ type V2ErrorCode,
+ v2Data,
+ v2Error,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2WorkflowImportAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+const ERROR_CODE_BY_STATUS: Record = {
+ 400: 'BAD_REQUEST',
+ 404: 'NOT_FOUND',
+ 409: 'CONFLICT',
+ 423: 'LOCKED',
+ 500: 'INTERNAL_ERROR',
+}
+
+/**
+ * POST /api/v2/workflows/import
+ *
+ * Creates a new workflow in the target workspace from an export payload
+ * produced by `GET /api/v2/workflows/{id}/export`. The shared
+ * {@link importWorkflowIntoWorkspace} pipeline does the heavy lifting; this
+ * route authenticates and renders the v2 envelope.
+ */
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'workflow-import')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2ImportWorkflowContract,
+ request,
+ {},
+ {
+ maxBodyBytes: MAX_IMPORT_BODY_BYTES,
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ const { workspaceId, folderPath, name, description } = parsed.data.body
+
+ logger.info(`[${requestId}] Importing workflow into workspace ${workspaceId}`, {
+ userId,
+ folderPath,
+ })
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const resolution = await resolveFolderPathIdentity({
+ workspaceId,
+ resourceType: 'workflow',
+ path: folderPath ?? '/',
+ })
+ if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found')
+
+ const result = await importWorkflowIntoWorkspace({
+ workspaceId,
+ folderId: resolution.folderId ?? undefined,
+ name,
+ description,
+ workflow: parsed.data.body.workflow,
+ userId,
+ requestId,
+ })
+
+ if (!result.success) {
+ return v2Error(ERROR_CODE_BY_STATUS[result.status] ?? 'INTERNAL_ERROR', result.error, {
+ status: result.status,
+ details: result.details,
+ })
+ }
+
+ return v2Data(
+ {
+ id: result.workflow.id,
+ name: result.workflow.name,
+ description: result.workflow.description,
+ workspaceId: result.workflow.workspaceId,
+ folderPath: folderPathForId(resolution.index, result.workflow.folderId),
+ createdAt: result.workflow.createdAt.toISOString(),
+ updatedAt: result.workflow.updatedAt.toISOString(),
+ },
+ { rateLimit, status: 201 }
+ )
+ } catch (error) {
+ logger.error(`[${requestId}] Workflow import error`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/v2/workflows/lib/access.ts b/apps/sim/app/api/v2/workflows/lib/access.ts
new file mode 100644
index 00000000000..404b820ac89
--- /dev/null
+++ b/apps/sim/app/api/v2/workflows/lib/access.ts
@@ -0,0 +1,63 @@
+import type { workflow as workflowTable } from '@sim/db/schema'
+import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
+import type { NextRequest, NextResponse } from 'next/server'
+import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils'
+import { authenticateV1Request } from '@/app/api/v1/auth'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import { v2Error } from '@/app/api/v2/lib/response'
+
+type WorkflowRecord = typeof workflowTable.$inferSelect
+
+export type V2WorkflowAccess =
+ | {
+ ok: true
+ userId: string
+ keyType: 'personal' | 'workspace' | undefined
+ workflow: WorkflowRecord
+ }
+ | { ok: false; response: NextResponse }
+
+/**
+ * X-API-Key auth + workflow authorization for the v2 execution sub-resources.
+ * Authorization failures and workspace-key scope mismatches are masked as 404
+ * so cross-workspace workflow existence never leaks; personal keys honor the
+ * workspace's `allowPersonalApiKeys` setting.
+ */
+export async function resolveV2WorkflowAccess(
+ request: NextRequest,
+ workflowId: string,
+ action: 'read' | 'write'
+): Promise {
+ const auth = await authenticateV1Request(request)
+ if (!auth.authenticated || !auth.userId) {
+ return { ok: false, response: v2Error('UNAUTHORIZED', auth.error || 'Unauthorized') }
+ }
+
+ const gate = await v2ApiGateError(auth.userId)
+ if (gate) return { ok: false, response: gate }
+
+ const authorization = await authorizeWorkflowByWorkspacePermission({
+ workflowId,
+ userId: auth.userId,
+ action,
+ })
+ if (!authorization.allowed || !authorization.workflow) {
+ return { ok: false, response: v2Error('NOT_FOUND', 'Workflow not found') }
+ }
+ const workflow = authorization.workflow as WorkflowRecord
+
+ if (auth.keyType === 'workspace' && workflow.workspaceId !== auth.workspaceId) {
+ return { ok: false, response: v2Error('NOT_FOUND', 'Workflow not found') }
+ }
+ if (auth.keyType === 'personal' && workflow.workspaceId) {
+ const settings = await getWorkspaceBillingSettings(workflow.workspaceId)
+ if (!settings?.allowPersonalApiKeys) {
+ return {
+ ok: false,
+ response: v2Error('FORBIDDEN', 'Personal API keys are not allowed for this workspace'),
+ }
+ }
+ }
+
+ return { ok: true, userId: auth.userId, keyType: auth.keyType, workflow }
+}
diff --git a/apps/sim/app/api/v2/workflows/route.test.ts b/apps/sim/app/api/v2/workflows/route.test.ts
new file mode 100644
index 00000000000..9ab8c6575ae
--- /dev/null
+++ b/apps/sim/app/api/v2/workflows/route.test.ts
@@ -0,0 +1,434 @@
+/**
+ * @vitest-environment node
+ *
+ * Public v2 workflow list: the search/sort/filter convention, and the keyset
+ * cursor's binding to the sort it was minted under. The assertions look at the
+ * WHERE/ORDER BY the route hands drizzle, because that is the whole point of
+ * the change — a search must narrow the query, not the result.
+ */
+import {
+ dbChainMockFns,
+ flattenMockConditions,
+ queueTableRows,
+ resetDbChainMock,
+ schemaMock,
+} from '@sim/testing'
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckRateLimit,
+ mockResolveWorkspaceAccess,
+ mockPerformCreateWorkflow,
+ mockAssertFolderMutable,
+ mockLoadActiveFolderPathIndex,
+ FolderLockedErrorMock,
+} = vi.hoisted(() => ({
+ mockCheckRateLimit: vi.fn(),
+ mockResolveWorkspaceAccess: vi.fn(),
+ mockPerformCreateWorkflow: vi.fn(),
+ mockAssertFolderMutable: vi.fn(),
+ mockLoadActiveFolderPathIndex: vi.fn(),
+ FolderLockedErrorMock: class FolderLockedError extends Error {
+ status = 423
+ },
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ checkRateLimit: mockCheckRateLimit,
+ resolveWorkspaceAccess: mockResolveWorkspaceAccess,
+}))
+
+vi.mock('@/lib/workflows/orchestration', () => ({
+ performCreateWorkflow: mockPerformCreateWorkflow,
+}))
+
+vi.mock('@sim/platform-authz/workflow', () => ({
+ assertFolderMutable: mockAssertFolderMutable,
+ FolderLockedError: FolderLockedErrorMock,
+}))
+
+vi.mock('@/lib/folders/queries', () => ({
+ loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex,
+}))
+
+vi.mock('@/app/api/v2/lib/gate', () => ({
+ v2ApiGateError: vi.fn().mockResolvedValue(null),
+}))
+
+import { GET, POST } from '@/app/api/v2/workflows/route'
+
+const WS = 'workspace-1'
+
+const RATE_LIMIT_OK = {
+ allowed: true,
+ userId: 'user-1',
+ keyType: 'workspace',
+ limit: 100,
+ remaining: 99,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+}
+
+function buildRow(overrides: Record = {}) {
+ return {
+ id: 'wf_1',
+ name: 'Daily digest',
+ description: null,
+ folderId: null,
+ workspaceId: WS,
+ isDeployed: false,
+ deployedAt: null,
+ runCount: 3,
+ lastRunAt: null,
+ sortOrder: 0,
+ createdAt: new Date('2024-01-01T00:00:00Z'),
+ updatedAt: new Date('2024-01-02T00:00:00Z'),
+ ...overrides,
+ }
+}
+
+const callList = (query: string) =>
+ GET(new NextRequest(`http://localhost:3000/api/v2/workflows?${query}`))
+
+/** The condition nodes the route passed to `.where()` on the last query. */
+const lastConditions = () =>
+ flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]).filter(Boolean)
+
+const lastOrderBy = () => dbChainMockFns.orderBy.mock.calls.at(-1) ?? []
+
+/**
+ * Timestamp keys order on `date_trunc('milliseconds', col)` rather than the raw
+ * column, so the mocked `sql` fragment carries the column in its interpolated
+ * values rather than being the column itself.
+ */
+const truncatedColumnOf = (entry: { column: { values?: unknown[] } }) => entry.column?.values?.[0]
+
+describe('GET /api/v2/workflows', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resetDbChainMock()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockLoadActiveFolderPathIndex.mockResolvedValue({
+ rowById: new Map(),
+ pathById: new Map(),
+ idByPath: new Map(),
+ })
+ })
+
+ it('narrows the query with a case-insensitive substring match on the name', async () => {
+ queueTableRows(schemaMock.workflow, [buildRow()])
+
+ const res = await callList(`workspaceId=${WS}&search=digest`)
+
+ expect(res.status).toBe(200)
+ const search = lastConditions().find((c) => c.type === 'ilike')
+ expect(search).toMatchObject({ column: schemaMock.workflow.name, pattern: '%digest%' })
+ })
+
+ it('escapes LIKE wildcards so a caller cannot widen its own match', async () => {
+ queueTableRows(schemaMock.workflow, [])
+
+ await callList(`workspaceId=${WS}&search=${encodeURIComponent('100%_x')}`)
+
+ expect(lastConditions().find((c) => c.type === 'ilike')).toMatchObject({
+ pattern: '%100\\%\\_x%',
+ })
+ })
+
+ it('adds no search condition when the caller did not search', async () => {
+ queueTableRows(schemaMock.workflow, [buildRow()])
+
+ await callList(`workspaceId=${WS}`)
+
+ expect(lastConditions().some((c) => c.type === 'ilike')).toBe(false)
+ })
+
+ it('treats folderPath=/ as root-only while omission lists every folder', async () => {
+ queueTableRows(schemaMock.workflow, [buildRow()])
+
+ await callList(`workspaceId=${WS}&folderPath=%2F`)
+
+ expect(
+ lastConditions().some(
+ (condition) =>
+ condition.type === 'isNull' && condition.column === schemaMock.workflow.folderId
+ )
+ ).toBe(true)
+ })
+
+ it('400s on a sort field outside the enum instead of letting it reach the query', async () => {
+ const res = await callList(`workspaceId=${WS}&sortBy=(select 1)`)
+
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(dbChainMockFns.where).not.toHaveBeenCalled()
+ })
+
+ it('400s on a sort direction outside the enum', async () => {
+ const res = await callList(`workspaceId=${WS}&sortOrder=sideways`)
+
+ expect(res.status).toBe(400)
+ expect(dbChainMockFns.where).not.toHaveBeenCalled()
+ })
+
+ it('400s on an empty search rather than treating it as unsearched', async () => {
+ const res = await callList(`workspaceId=${WS}&search=`)
+
+ expect(res.status).toBe(400)
+ expect(dbChainMockFns.where).not.toHaveBeenCalled()
+ })
+
+ it('defaults to the workspace position ordering', async () => {
+ queueTableRows(schemaMock.workflow, [buildRow()])
+
+ await callList(`workspaceId=${WS}`)
+
+ const orderBy = lastOrderBy()
+ expect(orderBy.map((e: { type: string }) => e.type)).toEqual(['asc', 'asc', 'asc'])
+ expect(orderBy[0].column).toBe(schemaMock.workflow.sortOrder)
+ expect(truncatedColumnOf(orderBy[1])).toBe(schemaMock.workflow.createdAt)
+ expect(orderBy[2].column).toBe(schemaMock.workflow.id)
+ })
+
+ it('orders by the requested field and direction', async () => {
+ queueTableRows(schemaMock.workflow, [buildRow()])
+
+ await callList(`workspaceId=${WS}&sortBy=name&sortOrder=desc`)
+
+ expect(lastOrderBy()).toEqual([
+ { type: 'desc', column: schemaMock.workflow.name },
+ { type: 'desc', column: schemaMock.workflow.id },
+ ])
+ })
+
+ it('combines a filter with a cursor into one consistent page', async () => {
+ queueTableRows(schemaMock.workflow, [buildRow(), buildRow({ id: 'wf_2', name: 'Zebra' })])
+
+ const first = await callList(`workspaceId=${WS}&search=a&sortBy=name&limit=1`)
+ const body = await first.json()
+
+ expect(body.data).toHaveLength(1)
+ expect(body.nextCursor).not.toBeNull()
+
+ queueTableRows(schemaMock.workflow, [buildRow({ id: 'wf_2', name: 'Zebra' })])
+ const second = await callList(
+ `workspaceId=${WS}&search=a&sortBy=name&limit=1&cursor=${encodeURIComponent(body.nextCursor)}`
+ )
+
+ expect(second.status).toBe(200)
+ const conditions = lastConditions()
+ // The filter survives the cursor page, and the keyset resumes from the last row.
+ expect(conditions.find((c) => c.type === 'ilike')).toMatchObject({ pattern: '%a%' })
+ expect(conditions.some((c) => c.type === 'or')).toBe(true)
+ })
+
+ it('terminates pagination once a filtered page is not full', async () => {
+ queueTableRows(schemaMock.workflow, [buildRow()])
+
+ const res = await callList(`workspaceId=${WS}&search=digest&limit=50`)
+
+ expect((await res.json()).nextCursor).toBeNull()
+ })
+
+ it('400s when a cursor is replayed under a different sort', async () => {
+ queueTableRows(schemaMock.workflow, [buildRow(), buildRow({ id: 'wf_2' })])
+
+ const first = await callList(`workspaceId=${WS}&sortBy=name&limit=1`)
+ const { nextCursor } = await first.json()
+ vi.clearAllMocks()
+
+ const res = await callList(
+ `workspaceId=${WS}&sortBy=createdAt&limit=1&cursor=${encodeURIComponent(nextCursor)}`
+ )
+
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.message).toMatch(/cursor does not match/i)
+ expect(dbChainMockFns.where).not.toHaveBeenCalled()
+ })
+
+ it('400s on a malformed cursor instead of silently restarting from page one', async () => {
+ const res = await callList(`workspaceId=${WS}&cursor=not-a-cursor`)
+
+ expect(res.status).toBe(400)
+ expect(dbChainMockFns.where).not.toHaveBeenCalled()
+ })
+})
+
+const RATE_LIMIT_DENIED = {
+ allowed: false,
+ limit: 100,
+ remaining: 0,
+ resetAt: new Date('2024-01-01T01:00:00Z'),
+ retryAfterMs: 1000,
+}
+
+const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' }
+
+const CREATED = {
+ id: 'wf-1',
+ name: 'Support Agent',
+ description: 'Handles tickets',
+ workspaceId: 'workspace-1',
+ folderId: null,
+ sortOrder: 0,
+ createdAt: new Date('2024-01-01T00:00:00Z'),
+ updatedAt: new Date('2024-01-01T00:00:00Z'),
+ startBlockId: 'block-1',
+ subBlockValues: {},
+}
+
+const VALID_BODY = {
+ workspaceId: 'workspace-1',
+ name: 'Support Agent',
+ description: 'Handles tickets',
+}
+
+function callPost(body: unknown) {
+ return POST(
+ new NextRequest('http://localhost:3000/api/v2/workflows', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ )
+}
+
+describe('POST /api/v2/workflows', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
+ mockResolveWorkspaceAccess.mockResolvedValue(null)
+ mockAssertFolderMutable.mockResolvedValue(undefined)
+ mockLoadActiveFolderPathIndex.mockResolvedValue({
+ rowById: new Map([['fld-1', { id: 'fld-1', name: 'Locked', parentId: null }]]),
+ pathById: new Map([['fld-1', '/Locked']]),
+ idByPath: new Map([['/Locked', 'fld-1']]),
+ })
+ mockPerformCreateWorkflow.mockResolvedValue({ success: true, workflow: CREATED })
+ })
+
+ it('returns 404 when the v2 API surface flag is off', async () => {
+ const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
+ const { v2Error } = await import('@/app/api/v2/lib/response')
+ vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
+
+ const res = await callPost(VALID_BODY)
+
+ expect(res.status).toBe(404)
+ expect(mockPerformCreateWorkflow).not.toHaveBeenCalled()
+ })
+
+ it('400s when name is missing', async () => {
+ const res = await callPost({ workspaceId: 'workspace-1' })
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('BAD_REQUEST')
+ expect(mockPerformCreateWorkflow).not.toHaveBeenCalled()
+ })
+
+ it('400s on an unknown body field', async () => {
+ const res = await callPost({ ...VALID_BODY, sortOrder: 3 })
+ expect(res.status).toBe(400)
+ expect(mockPerformCreateWorkflow).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an access-denied failure in the v2 error envelope', async () => {
+ mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED)
+ const res = await callPost(VALID_BODY)
+ expect(res.status).toBe(403)
+ expect(mockPerformCreateWorkflow).not.toHaveBeenCalled()
+ })
+
+ it('requires write access on the target workspace', async () => {
+ await callPost(VALID_BODY)
+ expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(
+ expect.anything(),
+ 'user-1',
+ 'workspace-1',
+ 'write'
+ )
+ })
+
+ it('returns the rate-limit response when denied', async () => {
+ mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
+ const res = await callPost(VALID_BODY)
+ expect(res.status).toBe(429)
+ expect((await res.json()).error.code).toBe('RATE_LIMITED')
+ })
+
+ it('423s when the destination folder is locked', async () => {
+ mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked'))
+ const res = await callPost({ ...VALID_BODY, folderPath: '/Locked' })
+ expect(res.status).toBe(423)
+ expect((await res.json()).error.code).toBe('LOCKED')
+ expect(mockPerformCreateWorkflow).not.toHaveBeenCalled()
+ })
+
+ it('404s a path outside the workspace without ever reading its lock state', async () => {
+ const res = await callPost({ ...VALID_BODY, folderPath: '/Elsewhere' })
+
+ expect(res.status).toBe(404)
+ expect((await res.json()).error.code).toBe('NOT_FOUND')
+ expect(mockAssertFolderMutable).not.toHaveBeenCalled()
+ expect(mockPerformCreateWorkflow).not.toHaveBeenCalled()
+ })
+
+ it('resolves the canonical path before checking mutability', async () => {
+ await callPost({ ...VALID_BODY, folderPath: '/Locked' })
+
+ expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith(
+ 'workspace-1',
+ 'workflow',
+ expect.any(Object)
+ )
+ expect(mockAssertFolderMutable).toHaveBeenCalledWith('fld-1')
+ })
+
+ it('skips the containment check when no folder is supplied', async () => {
+ await callPost(VALID_BODY)
+ expect(mockAssertFolderMutable).toHaveBeenCalledWith(null)
+ })
+
+ it('409s when the name is already taken in the target folder', async () => {
+ mockPerformCreateWorkflow.mockResolvedValue({
+ success: false,
+ error: 'A workflow named "Support Agent" already exists in this folder',
+ errorCode: 'conflict',
+ })
+ const res = await callPost(VALID_BODY)
+ expect(res.status).toBe(409)
+ expect((await res.json()).error.code).toBe('CONFLICT')
+ })
+
+ it('creates the workflow and returns 201 with the public shape', async () => {
+ const res = await callPost(VALID_BODY)
+ const body = await res.json()
+
+ expect(res.status).toBe(201)
+ expect(body).toEqual({
+ data: {
+ id: 'wf-1',
+ name: 'Support Agent',
+ description: 'Handles tickets',
+ folderPath: '/',
+ workspaceId: 'workspace-1',
+ isDeployed: false,
+ deployedAt: null,
+ runCount: 0,
+ lastRunAt: null,
+ createdAt: '2024-01-01T00:00:00.000Z',
+ updatedAt: '2024-01-01T00:00:00.000Z',
+ },
+ })
+ expect(res.headers.get('X-RateLimit-Remaining')).toBe('99')
+ expect(mockPerformCreateWorkflow).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId: 'user-1',
+ workspaceId: 'workspace-1',
+ name: 'Support Agent',
+ description: 'Handles tickets',
+ folderId: null,
+ })
+ )
+ })
+})
diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts
new file mode 100644
index 00000000000..b314a1500de
--- /dev/null
+++ b/apps/sim/app/api/v2/workflows/route.ts
@@ -0,0 +1,272 @@
+import { db } from '@sim/db'
+import { workflow } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { and, eq, isNull } from 'drizzle-orm'
+import type { NextRequest } from 'next/server'
+import {
+ type V2WorkflowListItem,
+ type V2WorkflowSortBy,
+ v2CreateWorkflowContract,
+ v2ListWorkflowsContract,
+} from '@/lib/api/contracts/v2/workflows'
+import {
+ encodeKeyset,
+ type KeysetKey,
+ keysetAfter,
+ keysetColumns,
+ listOrderBy,
+ numberKey,
+ searchFilter,
+ textKey,
+ timestampKey,
+} from '@/lib/api/list-query'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { loadActiveFolderPathIndex } from '@/lib/folders/queries'
+import { performCreateWorkflow } from '@/lib/workflows/orchestration'
+import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
+import {
+ folderPathForId,
+ resolveFolderPathId,
+ resolveFolderPathIdentity,
+} from '@/app/api/v2/lib/folders'
+import { v2ApiGateError } from '@/app/api/v2/lib/gate'
+import {
+ cursorSortKey,
+ decodeSortedCursor,
+ encodeSortedCursor,
+ v2CursorList,
+ v2CursorSortError,
+ v2Data,
+ v2Error,
+ v2ErrorForOrchestration,
+ v2RateLimitError,
+ v2ValidationError,
+ v2WorkspaceAccessError,
+} from '@/app/api/v2/lib/response'
+
+const logger = createLogger('V2WorkflowsAPI')
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+type WorkflowRow = {
+ id: string
+ name: string
+ sortOrder: number
+ runCount: number
+ createdAt: Date
+ updatedAt: Date
+}
+
+/**
+ * The keysets behind the sortable workflow fields. `satisfies` makes the map
+ * total over the contract enum, so a new sortable field cannot ship without an
+ * ordering. Every key column is `NOT NULL` and each keyset ends in `id`, which
+ * is what keeps a page boundary inside a run of equal values stable.
+ *
+ * `position` keeps its historical three-part ordering: workflows share a
+ * `sortOrder` freely, and dropping `createdAt` from the tiebreak would reshuffle
+ * every workspace's default list.
+ */
+const workflowId = textKey(workflow.id, (row) => row.id)
+const workflowCreatedAt = timestampKey(workflow.createdAt, (row) => row.createdAt)
+
+const WORKFLOW_SORTS = {
+ position: [
+ numberKey(workflow.sortOrder, (row) => row.sortOrder),
+ workflowCreatedAt,
+ workflowId,
+ ],
+ name: [textKey(workflow.name, (row) => row.name), workflowId],
+ createdAt: [workflowCreatedAt, workflowId],
+ updatedAt: [timestampKey(workflow.updatedAt, (row) => row.updatedAt), workflowId],
+ runCount: [numberKey(workflow.runCount, (row) => row.runCount), workflowId],
+} satisfies Record[]>
+
+export const GET = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'workflows')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2ListWorkflowsContract,
+ request,
+ {},
+ {
+ validationErrorResponse: v2ValidationError,
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ const params = parsed.data.query
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const folderIndex = await loadActiveFolderPathIndex(params.workspaceId, 'workflow')
+ const folderId =
+ params.folderPath === undefined
+ ? undefined
+ : resolveFolderPathId(folderIndex, params.folderPath)
+ if (params.folderPath !== undefined && folderId === undefined) {
+ return v2Error('NOT_FOUND', 'Folder not found')
+ }
+
+ const sortKey = cursorSortKey(params.sortBy, params.sortOrder)
+ const keys: readonly KeysetKey[] = WORKFLOW_SORTS[params.sortBy]
+ const decoded = decodeSortedCursor(params.cursor, sortKey)
+ if (decoded.status === 'invalid') return v2CursorSortError()
+
+ // `null` here is a cursor whose values don't fit this sort — a client error, not an empty page.
+ const resumeAfter =
+ decoded.status === 'ok' ? keysetAfter(keys, decoded.keys, params.sortOrder) : undefined
+ if (resumeAfter === null) return v2CursorSortError()
+
+ const conditions = [
+ eq(workflow.workspaceId, params.workspaceId),
+ isNull(workflow.archivedAt),
+ params.folderPath === undefined
+ ? undefined
+ : folderId === null
+ ? isNull(workflow.folderId)
+ : folderId === undefined
+ ? undefined
+ : eq(workflow.folderId, folderId),
+ params.deployedOnly ? eq(workflow.isDeployed, true) : undefined,
+ searchFilter(workflow.name, params.search),
+ resumeAfter,
+ ]
+
+ const rows = await db
+ .select({
+ id: workflow.id,
+ name: workflow.name,
+ description: workflow.description,
+ folderId: workflow.folderId,
+ workspaceId: workflow.workspaceId,
+ isDeployed: workflow.isDeployed,
+ deployedAt: workflow.deployedAt,
+ runCount: workflow.runCount,
+ lastRunAt: workflow.lastRunAt,
+ sortOrder: workflow.sortOrder,
+ createdAt: workflow.createdAt,
+ updatedAt: workflow.updatedAt,
+ })
+ .from(workflow)
+ .where(and(...conditions))
+ .orderBy(...listOrderBy(keysetColumns(keys), params.sortOrder))
+ .limit(params.limit + 1)
+
+ const hasMore = rows.length > params.limit
+ const data = rows.slice(0, params.limit)
+
+ const last = data.at(-1)
+ const nextCursor =
+ hasMore && last ? encodeSortedCursor(sortKey, encodeKeyset(keys, last)) : null
+
+ const formatted: V2WorkflowListItem[] = data.map((w) => ({
+ id: w.id,
+ name: w.name,
+ description: w.description,
+ folderPath: folderPathForId(folderIndex, w.folderId),
+ workspaceId: w.workspaceId ?? params.workspaceId,
+ isDeployed: w.isDeployed,
+ deployedAt: w.deployedAt?.toISOString() ?? null,
+ runCount: w.runCount,
+ lastRunAt: w.lastRunAt?.toISOString() ?? null,
+ createdAt: w.createdAt.toISOString(),
+ updatedAt: w.updatedAt.toISOString(),
+ }))
+
+ return v2CursorList(formatted, nextCursor, { rateLimit })
+ } catch (error) {
+ logger.error(`[${requestId}] Workflows fetch error`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
+
+/** POST /api/v2/workflows — Create an empty workflow in a workspace. */
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const rateLimit = await checkRateLimit(request, 'workflows')
+ if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
+
+ const userId = rateLimit.userId!
+
+ const gate = await v2ApiGateError(userId)
+ if (gate) return gate
+
+ const parsed = await parseRequest(
+ v2CreateWorkflowContract,
+ request,
+ {},
+ { validationErrorResponse: v2ValidationError }
+ )
+ if (!parsed.success) return parsed.response
+
+ const { workspaceId, name, description, folderPath } = parsed.data.body
+
+ const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
+ if (access) return v2WorkspaceAccessError(access)
+
+ const resolution = await resolveFolderPathIdentity({
+ workspaceId,
+ resourceType: 'workflow',
+ path: folderPath ?? '/',
+ })
+ if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found')
+
+ await assertFolderMutable(resolution.folderId)
+ const result = await performCreateWorkflow({
+ userId,
+ workspaceId,
+ name,
+ description,
+ folderId: resolution.folderId,
+ requestId,
+ })
+
+ if (!result.success || !result.workflow) {
+ return v2ErrorForOrchestration(result.errorCode, result.error ?? 'Failed to create workflow')
+ }
+
+ const created = result.workflow
+ const item: V2WorkflowListItem = {
+ id: created.id,
+ name: created.name,
+ description: created.description ?? null,
+ folderPath: folderPathForId(resolution.index, created.folderId),
+ workspaceId: created.workspaceId,
+ isDeployed: false,
+ deployedAt: null,
+ runCount: 0,
+ lastRunAt: null,
+ createdAt: created.createdAt.toISOString(),
+ updatedAt: created.updatedAt.toISOString(),
+ }
+
+ return v2Data(item, { rateLimit, status: 201 })
+ } catch (error) {
+ if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message)
+
+ logger.error(`[${requestId}] Workflow create error`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return v2Error('INTERNAL_ERROR', 'Internal server error')
+ }
+})
diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts
index 4c7e1027161..4fb34919b76 100644
--- a/apps/sim/app/api/workflows/[id]/deploy/route.ts
+++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts
@@ -7,6 +7,7 @@ import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { updatePublicApiContract } from '@/lib/api/contracts/deployments'
import { parseRequest } from '@/lib/api/server'
+import { statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
@@ -15,7 +16,6 @@ import {
performFullDeploy,
performFullUndeploy,
} from '@/lib/workflows/orchestration'
-import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import {
checkNeedsRedeployment,
diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts
index d3c3337e62f..5d5300ec13d 100644
--- a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts
+++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts
@@ -4,10 +4,10 @@ import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { updateDeploymentVersionMetadataContract } from '@/lib/api/contracts/deployments'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
+import { statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { performActivateVersion } from '@/lib/workflows/orchestration'
-import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types'
import {
getWorkflowDeploymentVersion,
updateDeploymentVersionMetadata,
diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts
index 476e600112c..72d0f71e04d 100644
--- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts
+++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts
@@ -1887,4 +1887,60 @@ describe('workflow execute async route', () => {
: executionCall.snapshot
expect(snapshot.metadata.enforceCredentialAccess).toBe(true)
})
+ describe('triggerType override gate', () => {
+ it.each([
+ ['personal API key', EXECUTION_CALLERS[1]],
+ ['workspace API key', EXECUTION_CALLERS[2]],
+ ['public API', EXECUTION_CALLERS[3]],
+ ] as const)(
+ 'rejects caller-supplied triggerType "manual" from %s callers',
+ async (_name, caller) => {
+ configureExecutionCaller(caller)
+ const req = createMockRequest(
+ 'POST',
+ { hello: 'world', triggerType: 'manual' },
+ { 'Content-Type': 'application/json', ...caller.headers }
+ )
+
+ const response = await POST(req, { params: Promise.resolve({ id: 'workflow-1' }) })
+
+ expect(response.status).toBe(400)
+ await expect(response.json()).resolves.toMatchObject({
+ error: 'External callers cannot override triggerType',
+ })
+ expect(mockPreprocessExecution).not.toHaveBeenCalled()
+ }
+ )
+
+ it('accepts the redundant explicit "api" triggerType from API-key callers', async () => {
+ const caller = EXECUTION_CALLERS[1]
+ configureExecutionCaller(caller)
+ const req = createMockRequest(
+ 'POST',
+ { hello: 'world', triggerType: 'api' },
+ { 'Content-Type': 'application/json', ...caller.headers, 'X-Execution-Mode': 'async' }
+ )
+
+ const response = await POST(req, { params: Promise.resolve({ id: 'workflow-1' }) })
+
+ expect(response.status).toBe(202)
+ })
+
+ it('still allows internal JWT callers to set triggerType', async () => {
+ const caller = EXECUTION_CALLERS[4]
+ configureExecutionCaller(caller)
+ const req = createMockRequest(
+ 'POST',
+ { hello: 'world', triggerType: 'workflow' },
+ { 'Content-Type': 'application/json', ...caller.headers, 'X-Execution-Mode': 'async' }
+ )
+
+ const response = await POST(req, { params: Promise.resolve({ id: 'workflow-1' }) })
+
+ expect(response.status).toBe(202)
+ expect(mockPreprocessExecution).toHaveBeenCalledWith(
+ expect.objectContaining({ triggerType: 'workflow' })
+ )
+ })
+ })
})
diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts
index 466aab44d48..9817acc81b0 100644
--- a/apps/sim/app/api/workflows/[id]/execute/route.ts
+++ b/apps/sim/app/api/workflows/[id]/execute/route.ts
@@ -28,8 +28,6 @@ import {
} from '@/lib/copilot/async-runs/repository'
import { isWorkflowToolName, resolveWorkflowToolTargetId } from '@/lib/copilot/tools/workflow-tools'
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
-import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
-import { isAsyncJobEnqueueError } from '@/lib/core/async-jobs/types'
import {
createTimeoutAbortController,
getTimeoutErrorMessage,
@@ -84,6 +82,7 @@ import {
hydrateUserFilesWithBase64,
} from '@/lib/uploads/utils/user-file-base64.server'
import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/operations'
+import { enqueueWorkflowExecution } from '@/lib/workflows/executor/enqueue-execution'
import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow'
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import {
@@ -120,7 +119,6 @@ import {
} from '@/lib/workflows/streaming/streaming'
import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils'
import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils'
-import { executeWorkflowJob, type WorkflowExecutionPayload } from '@/background/workflow-execution'
import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay'
import {
PublicApiNotAllowedError,
@@ -148,8 +146,6 @@ import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/
const logger = createLogger('WorkflowExecuteAPI')
const MAX_WORKFLOW_EXECUTE_BODY_BYTES = 10 * 1024 * 1024
const SERVER_EXECUTION_ID_CLAIM_ATTEMPTS = 3
-const ASYNC_ENQUEUE_ATTEMPTS = 2
-const WORKFLOW_EXECUTION_JOB_ID_PREFIX = 'workflow-execution:'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
@@ -405,170 +401,38 @@ function requirePreprocessedExecutionContext(
}
async function handleAsyncExecution(params: AsyncExecutionParams): Promise {
- const {
- requestId,
- workflowId,
- userId,
- billingAttribution,
- workspaceId,
- input,
- triggerType,
- executionId,
- callChain,
- } = params
- const asyncLogger = logger.withMetadata({
- requestId,
- workflowId,
- workspaceId,
- userId,
- executionId,
- })
-
- const correlation = {
- executionId,
- requestId,
- source: 'workflow' as const,
- workflowId,
- triggerType,
- }
-
- const payload: WorkflowExecutionPayload = {
- workflowId,
- userId,
- billingAttribution,
- workspaceId,
- input,
- triggerType,
- executionId,
- requestId,
- correlation,
- callChain,
- executionMode: 'async',
- admissionCompleted: true,
- }
+ const enqueue = await enqueueWorkflowExecution(params)
- let jobQueue: Awaited>
- try {
- jobQueue = await getJobQueue()
- } catch (error) {
- asyncLogger.error('Failed to initialize async execution queue', {
- error: toError(error).message,
- })
- await releaseExecutionSlot(executionId)
+ if (enqueue.outcome === 'rejected') {
return {
response: NextResponse.json({ error: 'Failed to queue async execution' }, { status: 500 }),
retainExecutionClaim: false,
}
}
- const deterministicJobId = `${WORKFLOW_EXECUTION_JOB_ID_PREFIX}${executionId}`
- const enqueueOptions = {
- jobId: deterministicJobId,
- metadata: { workflowId, workspaceId, userId, correlation },
- }
- let jobId: string | undefined
- let enqueueError: unknown
- let acceptanceCouldBeUnknown = false
-
- for (let attempt = 1; attempt <= ASYNC_ENQUEUE_ATTEMPTS; attempt++) {
- try {
- jobId = await jobQueue.enqueue('workflow-execution', payload, enqueueOptions)
- enqueueError = undefined
- break
- } catch (error) {
- enqueueError = error
- const classifiedError = isAsyncJobEnqueueError(error) ? error : undefined
- const attemptAcceptance = classifiedError?.acceptance ?? 'unknown'
- acceptanceCouldBeUnknown ||= attemptAcceptance === 'unknown'
- asyncLogger.warn('Async workflow enqueue attempt failed', {
- acceptance: attemptAcceptance,
- attempt,
- error: toError(error).message,
- jobId: deterministicJobId,
- })
- if (classifiedError?.retryable === false || attempt === ASYNC_ENQUEUE_ATTEMPTS) {
- break
- }
- }
- }
-
- if (!jobId) {
- const acceptance = acceptanceCouldBeUnknown
- ? 'unknown'
- : isAsyncJobEnqueueError(enqueueError)
- ? enqueueError.acceptance
- : 'unknown'
- asyncLogger.error('Failed to queue async execution', {
- acceptance,
- error: toError(enqueueError).message,
- jobId: deterministicJobId,
- })
-
- if (acceptance === 'rejected') {
- await releaseExecutionSlot(executionId)
- return {
- response: NextResponse.json({ error: 'Failed to queue async execution' }, { status: 500 }),
- retainExecutionClaim: false,
- }
- }
-
+ if (enqueue.outcome === 'ambiguous') {
return {
response: NextResponse.json(
{
error: 'Async execution queue acceptance could not be confirmed',
code: 'ASYNC_ENQUEUE_AMBIGUOUS',
- executionId,
+ executionId: enqueue.executionId,
},
- { status: 503, headers: { [WORKFLOW_EXECUTION_ID_HEADER]: executionId } }
+ { status: 503, headers: { [WORKFLOW_EXECUTION_ID_HEADER]: enqueue.executionId } }
),
retainExecutionClaim: true,
}
}
- asyncLogger.info('Queued async workflow execution', { jobId })
-
- if (shouldExecuteInline()) {
- void (async () => {
- let workerOwnsReservation = false
- try {
- await jobQueue.startJob(jobId)
- workerOwnsReservation = true
- const output = await executeWorkflowJob(payload)
- await jobQueue.completeJob(jobId, output)
- } catch (error) {
- const errorMessage = toError(error).message
- asyncLogger.error('Async workflow execution failed', {
- jobId,
- error: errorMessage,
- })
- /**
- * Before worker ownership transfers, no LoggingSession exists to
- * release the route's reservation.
- */
- if (!workerOwnsReservation) {
- await releaseExecutionSlot(executionId)
- }
- try {
- await jobQueue.markJobFailed(jobId, errorMessage)
- } catch (markFailedError) {
- asyncLogger.error('Failed to mark job as failed', {
- jobId,
- error: toError(markFailedError).message,
- })
- }
- }
- })()
- }
-
return {
response: NextResponse.json(
{
success: true,
async: true,
- jobId,
- executionId,
+ jobId: enqueue.jobId,
+ executionId: enqueue.executionId,
message: 'Workflow execution queued',
- statusUrl: `${getBaseUrl()}/api/jobs/${jobId}`,
+ statusUrl: `${getBaseUrl()}/api/jobs/${enqueue.jobId}`,
},
{ status: 202 }
),
@@ -810,6 +674,24 @@ async function handleExecutePost(
)
}
+ /**
+ * External callers may not override the trigger type: `manual`/`chat` turn
+ * rate limiting off entirely (`preprocessExecution` defaults `checkRateLimit`
+ * from the trigger type), so a caller-supplied value is a quota bypass.
+ * `'api'` (the value they would get anyway) stays accepted for compatibility
+ * with callers that send it redundantly.
+ */
+ if (
+ (auth.authType === AuthType.API_KEY || isPublicApiAccess) &&
+ body.triggerType !== undefined &&
+ body.triggerType !== 'api'
+ ) {
+ return NextResponse.json(
+ { error: 'External callers cannot override triggerType' },
+ { status: 400 }
+ )
+ }
+
if (inputFromExecutionId && (isPublicApiAccess || auth.authType !== AuthType.SESSION)) {
return NextResponse.json(
{ error: 'Stored execution input can only be reused by an authenticated session' },
diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts
index 0ca39eb6622..6f5656a8a7e 100644
--- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts
+++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts
@@ -1,100 +1,14 @@
-import { db } from '@sim/db'
-import { workflowExecutionLogs } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { toError } from '@sim/utils/errors'
-import { sleep } from '@sim/utils/helpers'
-import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { cancelWorkflowExecutionContract } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import {
- type ExecutionCancellationRecordResult,
- markExecutionCancelled,
-} from '@/lib/execution/cancellation'
-import { createExecutionEventWriter, readExecutionMetaState } from '@/lib/execution/event-buffer'
-import { abortManualExecution } from '@/lib/execution/manual-cancellation'
-import { captureServerEvent } from '@/lib/posthog/server'
-import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
+import { cancelWorkflowExecution } from '@/lib/execution/cancel-workflow-execution'
const logger = createLogger('CancelExecutionAPI')
-const PAUSED_CANCELLATION_DB_ATTEMPTS = 3
-const PAUSED_CANCELLATION_DB_RETRY_MS = 200
-
-async function completePausedCancellationWithRetry(
- executionId: string,
- workflowId: string
-): Promise {
- for (let attempt = 1; attempt <= PAUSED_CANCELLATION_DB_ATTEMPTS; attempt++) {
- try {
- const cancelled = await PauseResumeManager.completePausedCancellation(executionId, workflowId)
- if (cancelled) {
- logger.info('Paused execution cancelled in database', { executionId, attempt })
- return true
- }
- logger.warn('Paused execution cancellation could not be completed in database', {
- executionId,
- attempt,
- })
- return false
- } catch (error) {
- logger.warn('Failed to complete paused execution cancellation in database', {
- executionId,
- attempt,
- error,
- })
- if (attempt < PAUSED_CANCELLATION_DB_ATTEMPTS) {
- await sleep(PAUSED_CANCELLATION_DB_RETRY_MS)
- }
- }
- }
- return false
-}
-
-async function ensurePausedCancellationEventPublished(
- executionId: string,
- workflowId: string,
- context: { workspaceId?: string; userId?: string } = {}
-): Promise {
- const metaState = await readExecutionMetaState(executionId)
- if (metaState.status === 'found' && metaState.meta.status === 'cancelled') {
- return true
- }
-
- const writer = createExecutionEventWriter(executionId, {
- workspaceId: context.workspaceId,
- workflowId,
- userId: context.userId,
- })
- try {
- await writer.writeTerminal(
- {
- type: 'execution:cancelled',
- timestamp: new Date().toISOString(),
- executionId,
- workflowId,
- data: { duration: 0 },
- },
- 'cancelled'
- )
- return true
- } catch (error) {
- logger.warn('Failed to publish paused execution cancellation event', {
- executionId,
- error,
- })
- return false
- } finally {
- await writer.close().catch((error) => {
- logger.warn('Failed to close paused cancellation event writer', {
- executionId,
- error,
- })
- })
- }
-}
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
@@ -135,182 +49,14 @@ export const POST = withRouteHandler(
logger.info('Cancel execution requested', { workflowId, executionId, userId: auth.userId })
- let pausedCancellationStarted = false
- let pausedCancelled = false
- try {
- pausedCancellationStarted = await PauseResumeManager.beginPausedCancellation(
- executionId,
- workflowId
- )
- } catch (error) {
- logger.warn('Failed to begin paused execution cancellation in database', {
- executionId,
- error,
- })
- }
- const pendingPausedCancellation = pausedCancellationStarted
- ? null
- : await PauseResumeManager.getPausedCancellationStatus(executionId, workflowId)
- const isPausedCancellationPath =
- pausedCancellationStarted || pendingPausedCancellation !== null
-
- const cancellation: ExecutionCancellationRecordResult = isPausedCancellationPath
- ? { durablyRecorded: false, reason: 'redis_unavailable' }
- : await markExecutionCancelled(executionId)
- const locallyAborted = isPausedCancellationPath ? false : abortManualExecution(executionId)
-
- if (pausedCancellationStarted) {
- logger.info('Paused execution cancellation reserved in database', { executionId })
- } else if (cancellation.durablyRecorded) {
- logger.info('Execution marked as cancelled in Redis', { executionId })
- } else if (locallyAborted) {
- logger.info('Execution cancelled via local in-process fallback', { executionId })
- } else if (!pausedCancellationStarted) {
- logger.warn('Execution cancellation was not durably recorded', {
- executionId,
- reason: cancellation.reason,
- })
- }
-
- if (!isPausedCancellationPath && (cancellation.durablyRecorded || locallyAborted)) {
- await PauseResumeManager.blockQueuedResumesForCancellation(executionId, workflowId).catch(
- (error) => {
- logger.warn('Failed to block queued paused resumes after cancellation', {
- executionId,
- error,
- })
- }
- )
- } else if (!isPausedCancellationPath) {
- await PauseResumeManager.clearPausedCancellationIntent(executionId, workflowId).catch(
- (error) => {
- logger.warn(
- 'Failed to clear paused cancellation intent after unsuccessful cancellation',
- {
- executionId,
- error,
- }
- )
- }
- )
- }
-
- let pausedCancellationPublished = false
- let pausedCancellationPublishFailed = false
- if (pausedCancellationStarted) {
- pausedCancellationPublished = await ensurePausedCancellationEventPublished(
- executionId,
- workflowId,
- {
- workspaceId: workflowAuthorization.workflow?.workspaceId ?? undefined,
- userId: auth.userId,
- }
- )
- pausedCancellationPublishFailed = !pausedCancellationPublished
- if (pausedCancellationPublished) {
- pausedCancelled = await completePausedCancellationWithRetry(executionId, workflowId)
- }
- } else {
- if (pendingPausedCancellation === 'cancelled') {
- pausedCancellationPublished = await ensurePausedCancellationEventPublished(
- executionId,
- workflowId,
- {
- workspaceId: workflowAuthorization.workflow?.workspaceId ?? undefined,
- userId: auth.userId,
- }
- )
- pausedCancellationPublishFailed = !pausedCancellationPublished
- pausedCancelled = pausedCancellationPublished
- } else if (pendingPausedCancellation === 'cancelling') {
- pausedCancellationPublished = await ensurePausedCancellationEventPublished(
- executionId,
- workflowId,
- {
- workspaceId: workflowAuthorization.workflow?.workspaceId ?? undefined,
- userId: auth.userId,
- }
- )
- pausedCancellationPublishFailed = !pausedCancellationPublished
- if (pausedCancellationPublished) {
- pausedCancelled = await completePausedCancellationWithRetry(executionId, workflowId)
- }
- }
- }
-
- if (
- pausedCancellationPublishFailed &&
- (pausedCancellationStarted || pendingPausedCancellation === 'cancelling')
- ) {
- await PauseResumeManager.clearPausedCancellationIntent(executionId, workflowId).catch(
- (error) => {
- logger.warn('Failed to clear paused cancellation intent after publish failure', {
- executionId,
- error,
- })
- }
- )
- }
-
- if ((cancellation.durablyRecorded || locallyAborted) && !pausedCancelled) {
- try {
- await db
- .update(workflowExecutionLogs)
- .set({ status: 'cancelled', endedAt: new Date() })
- .where(
- and(
- eq(workflowExecutionLogs.executionId, executionId),
- eq(workflowExecutionLogs.status, 'running')
- )
- )
- } catch (dbError) {
- logger.warn('Failed to update execution log status directly', {
- executionId,
- error: dbError,
- })
- }
- }
-
- const success =
- (isPausedCancellationPath
- ? pausedCancelled && pausedCancellationPublished
- : cancellation.durablyRecorded) || locallyAborted
-
- if (success) {
- const workspaceId = workflowAuthorization.workflow?.workspaceId
- captureServerEvent(
- auth.userId,
- 'workflow_execution_cancelled',
- { workflow_id: workflowId, workspace_id: workspaceId ?? '' },
- workspaceId ? { groups: { workspace: workspaceId } } : undefined
- )
- }
-
- const durablyRecorded = isPausedCancellationPath
- ? pausedCancellationPublished
- : pausedCancelled || cancellation.durablyRecorded
- const reason = pausedCancellationPublishFailed
- ? 'paused_event_publish_failed'
- : !pausedCancelled && isPausedCancellationPath
- ? 'paused_database_cancel_failed'
- : pausedCancelled && !pausedCancellationPublished
- ? 'paused_event_publish_failed'
- : pausedCancelled || isPausedCancellationPath
- ? 'recorded'
- : cancellation.reason
-
- return NextResponse.json({
- success,
+ const result = await cancelWorkflowExecution({
executionId,
- redisAvailable:
- isPausedCancellationPath || pausedCancelled
- ? pausedCancellationPublished
- : cancellation.reason !== 'redis_unavailable',
- durablyRecorded,
- locallyAborted,
- pausedCancelled,
- reason,
+ workflowId,
+ userId: auth.userId,
+ workspaceId: workflowAuthorization.workflow?.workspaceId ?? undefined,
})
+
+ return NextResponse.json(result)
} catch (error) {
logger.error('Failed to cancel execution', {
workflowId,
diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts
index d7923d662bc..0b0e17f6e43 100644
--- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts
+++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts
@@ -1,89 +1,16 @@
-import { db } from '@sim/db'
-import { pausedExecutions, workflowExecutionLogs } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
-import {
- getWorkflowExecutionContract,
- type WorkflowExecutionStatusResponse,
-} from '@/lib/api/contracts/workflows'
+import { getWorkflowExecutionContract } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
- collectFunctionalBlockOutputs,
FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE,
- type FunctionalExecutionDataSource,
FunctionalOutputsUnavailableError,
} from '@/lib/logs/execution/functional-outputs'
-import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
-import { getAutomaticResumeWaitingMetadata } from '@/lib/workflows/executor/paused-execution-metadata'
+import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
-import type { PausePoint } from '@/executor/types'
const logger = createLogger('WorkflowExecutionStatusAPI')
-
-type LogStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
-
-interface ExecutionDataShape extends FunctionalExecutionDataSource {
- finalOutput?: { error?: string } & Record
- error?: { message?: string } | string
- completionFailure?: string
-}
-
-function resolvePath(value: unknown, path: string[]): unknown {
- let current: unknown = value
- for (const segment of path) {
- if (current == null || typeof current !== 'object') return undefined
- current = (current as Record)[segment]
- }
- return current
-}
-
-function pickSelectedOutputs(
- selectedOutputs: string[],
- blockOutputs: Map
-): Record {
- const out: Record = {}
- for (const selector of selectedOutputs) {
- const [head, ...rest] = selector.split('.')
- if (!head) continue
- if (!blockOutputs.has(head)) continue
- const blockValue = blockOutputs.get(head)
- out[selector] = rest.length === 0 ? blockValue : resolvePath(blockValue, rest)
- }
- return out
-}
-
-function pickEarliestPausePoint(points: PausePoint[]): PausePoint | null {
- const active = points.filter((p) => p.resumeStatus === 'paused')
- if (active.length === 0) return null
- return active.reduce((best, current) => {
- if (!best) return current
- if (!current.resumeAt) return best
- if (!best.resumeAt) return current
- return current.resumeAt < best.resumeAt ? current : best
- }, null)
-}
-
-function normalizePausePoints(raw: unknown): PausePoint[] {
- if (!raw) return []
- if (Array.isArray(raw)) return raw as PausePoint[]
- if (typeof raw === 'object') return Object.values(raw as Record)
- return []
-}
-
-function extractError(executionData: unknown): string | null {
- if (!executionData || typeof executionData !== 'object') return null
- const data = executionData as ExecutionDataShape
- if (typeof data.error === 'string') return data.error
- if (data.error && typeof data.error === 'object' && typeof data.error.message === 'string') {
- return data.error.message
- }
- if (typeof data.finalOutput?.error === 'string') return data.finalOutput.error
- if (typeof data.completionFailure === 'string') return data.completionFailure
- return null
-}
-
export const GET = withRouteHandler(
async (
request: NextRequest,
@@ -99,136 +26,31 @@ export const GET = withRouteHandler(
return NextResponse.json({ error: access.error.message }, { status: access.error.status })
}
- const [logRow] = await db
- .select({
- executionId: workflowExecutionLogs.executionId,
- workflowId: workflowExecutionLogs.workflowId,
- workspaceId: workflowExecutionLogs.workspaceId,
- status: workflowExecutionLogs.status,
- level: workflowExecutionLogs.level,
- trigger: workflowExecutionLogs.trigger,
- startedAt: workflowExecutionLogs.startedAt,
- endedAt: workflowExecutionLogs.endedAt,
- totalDurationMs: workflowExecutionLogs.totalDurationMs,
- executionData: workflowExecutionLogs.executionData,
- costTotal: workflowExecutionLogs.costTotal,
+ let status
+ try {
+ status = await getWorkflowExecutionStatus({
+ workflowId,
+ executionId,
+ includeOutput,
+ selectedOutputs,
})
- .from(workflowExecutionLogs)
- .where(
- and(
- eq(workflowExecutionLogs.executionId, executionId),
- eq(workflowExecutionLogs.workflowId, workflowId)
- )
- )
- .limit(1)
-
- if (!logRow) {
- return NextResponse.json({ error: 'Execution not found' }, { status: 404 })
- }
-
- const [pausedRow] = await db
- .select({
- id: pausedExecutions.id,
- status: pausedExecutions.status,
- pausePoints: pausedExecutions.pausePoints,
- metadata: pausedExecutions.metadata,
- resumedCount: pausedExecutions.resumedCount,
- pausedAt: pausedExecutions.pausedAt,
- nextResumeAt: pausedExecutions.nextResumeAt,
- })
- .from(pausedExecutions)
- .where(eq(pausedExecutions.executionId, executionId))
- .limit(1)
-
- const isCurrentlyPaused =
- !!pausedRow && (pausedRow.status === 'paused' || pausedRow.status === 'partially_resumed')
-
- let status: WorkflowExecutionStatusResponse['status']
- if (isCurrentlyPaused) {
- status = 'paused'
- } else {
- status = logRow.status as LogStatus
- }
-
- let paused: WorkflowExecutionStatusResponse['paused'] = null
- if (isCurrentlyPaused && pausedRow) {
- const points = normalizePausePoints(pausedRow.pausePoints)
- const earliest = pickEarliestPausePoint(points)
- const automaticResumeWaiting = getAutomaticResumeWaitingMetadata(pausedRow.metadata)
- paused = {
- pausedAt: pausedRow.pausedAt.toISOString(),
- resumeAt: pausedRow.nextResumeAt?.toISOString() ?? earliest?.resumeAt ?? null,
- pauseKind: earliest?.pauseKind ?? null,
- blockedOnBlockId: earliest?.blockId ?? null,
- automaticResumeWaitingReason:
- automaticResumeWaiting?.reason ?? earliest?.automaticResumeWaitingReason ?? null,
- pausedExecutionId: pausedRow.id,
- pausePointCount: points.length,
- resumedCount: pausedRow.resumedCount,
+ } catch (error) {
+ if (error instanceof FunctionalOutputsUnavailableError) {
+ return NextResponse.json({ error: FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE }, { status: 409 })
}
+ throw error
}
- const cost = logRow.costTotal != null ? { total: Number(logRow.costTotal) } : null
-
- // Heavy execution data may live in object storage; resolve the pointer
- // before reading error / finalOutput / traceSpans (no-op for inline rows).
- const executionData = (await materializeExecutionData(
- logRow.executionData as Record | null,
- {
- workspaceId: logRow.workspaceId,
- workflowId: logRow.workflowId,
- executionId: logRow.executionId,
- }
- )) as ExecutionDataShape | undefined
-
- const error = status === 'failed' ? extractError(executionData) : null
-
- const finalOutput =
- includeOutput && status === 'completed' && executionData
- ? (executionData.finalOutput ?? null)
- : null
-
- let blockOutputs: Record | null = null
- if (selectedOutputs.length > 0) {
- try {
- blockOutputs = pickSelectedOutputs(
- selectedOutputs,
- collectFunctionalBlockOutputs(executionData)
- )
- } catch (error) {
- if (error instanceof FunctionalOutputsUnavailableError) {
- return NextResponse.json(
- { error: FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE },
- { status: 409 }
- )
- }
- throw error
- }
- }
-
- const response: WorkflowExecutionStatusResponse = {
- executionId: logRow.executionId,
- workflowId: logRow.workflowId ?? workflowId,
- status,
- trigger: logRow.trigger,
- level: logRow.level,
- startedAt: logRow.startedAt.toISOString(),
- endedAt: logRow.endedAt?.toISOString() ?? null,
- totalDurationMs: logRow.totalDurationMs ?? null,
- paused,
- cost,
- error,
- finalOutput,
- blockOutputs,
+ if (!status) {
+ return NextResponse.json({ error: 'Execution not found' }, { status: 404 })
}
-
logger.debug('Fetched execution status', {
workflowId,
executionId,
- status,
- paused: !!paused,
+ status: status.status,
+ paused: !!status.paused,
})
- return NextResponse.json(response)
+ return NextResponse.json(status)
}
)
diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts
new file mode 100644
index 00000000000..78f5cdb124f
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts
@@ -0,0 +1,135 @@
+/**
+ * @vitest-environment node
+ */
+import { authMockFns } from '@sim/testing'
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockGetUserEntityPermissions, mockPerformUpdateContent } = vi.hoisted(() => ({
+ mockGetUserEntityPermissions: vi.fn(),
+ mockPerformUpdateContent: vi.fn(),
+}))
+
+vi.mock('@/lib/workspace-files/orchestration', () => ({
+ MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024,
+ performUpdateWorkspaceFileContent: mockPerformUpdateContent,
+}))
+
+vi.mock('@/lib/workspaces/permissions/utils', () => ({
+ getUserEntityPermissions: mockGetUserEntityPermissions,
+}))
+
+import { PUT } from '@/app/api/workspaces/[id]/files/[fileId]/content/route'
+
+const WORKSPACE_ID = 'workspace-1'
+const FILE_ID = 'wf_1'
+const USER = { id: 'user-1', name: 'Test User', email: 'test@sim.ai' }
+const RECORD = {
+ id: FILE_ID,
+ workspaceId: WORKSPACE_ID,
+ name: 'notes.md',
+ key: `workspace/${WORKSPACE_ID}/notes.md`,
+ path: '/api/files/serve/notes.md?context=workspace',
+ size: 5,
+ type: 'text/markdown',
+ uploadedBy: USER.id,
+ folderId: null,
+ folderPath: null,
+ uploadedAt: new Date('2026-08-04T00:00:00.000Z'),
+ updatedAt: new Date('2026-08-04T00:00:00.000Z'),
+}
+
+const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID, fileId: FILE_ID }) }
+
+function createRequest(body: unknown, contentLength?: number): NextRequest {
+ return new NextRequest(
+ `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/${FILE_ID}/content`,
+ {
+ method: 'PUT',
+ headers: {
+ 'content-type': 'application/json',
+ ...(contentLength === undefined ? {} : { 'content-length': String(contentLength) }),
+ },
+ body: typeof body === 'string' ? body : JSON.stringify(body),
+ }
+ )
+}
+
+describe('PUT /api/workspaces/[id]/files/[fileId]/content', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ authMockFns.mockGetSession.mockResolvedValue({ user: USER })
+ mockGetUserEntityPermissions.mockResolvedValue('write')
+ mockPerformUpdateContent.mockResolvedValue({ success: true, file: RECORD })
+ })
+
+ it('authenticates before parsing an invalid request body', async () => {
+ authMockFns.mockGetSession.mockResolvedValue(null)
+
+ const response = await PUT(createRequest('{not-json'), routeContext)
+
+ expect(response.status).toBe(401)
+ await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' })
+ expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
+ expect(mockPerformUpdateContent).not.toHaveBeenCalled()
+ })
+
+ it('authorizes the workspace before parsing the request body', async () => {
+ mockGetUserEntityPermissions.mockResolvedValue('read')
+
+ const response = await PUT(createRequest('{not-json'), routeContext)
+
+ expect(response.status).toBe(403)
+ await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' })
+ expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER.id, 'workspace', WORKSPACE_ID)
+ expect(mockPerformUpdateContent).not.toHaveBeenCalled()
+ })
+
+ it('rejects malformed base64 after authorization', async () => {
+ const response = await PUT(
+ createRequest({ content: 'not-base64!', encoding: 'base64' }),
+ routeContext
+ )
+
+ expect(response.status).toBe(400)
+ await expect(response.json()).resolves.toMatchObject({ error: 'Validation error' })
+ expect(mockPerformUpdateContent).not.toHaveBeenCalled()
+ })
+
+ it('accepts empty base64 as a zero-byte replacement', async () => {
+ const request = createRequest({ content: '', encoding: 'base64' })
+ const response = await PUT(request, routeContext)
+
+ expect(response.status).toBe(200)
+ expect(mockPerformUpdateContent).toHaveBeenCalledWith({
+ workspaceId: WORKSPACE_ID,
+ fileId: FILE_ID,
+ userId: USER.id,
+ content: '',
+ encoding: 'base64',
+ actorName: USER.name,
+ actorEmail: USER.email,
+ request,
+ })
+ })
+
+ it('allows JSON bodies above the default 50 MiB cap for base64 expansion', async () => {
+ const response = await PUT(
+ createRequest({ content: 'TQ==', encoding: 'base64' }, 60 * 1024 * 1024),
+ routeContext
+ )
+
+ expect(response.status).toBe(200)
+ expect(mockPerformUpdateContent).toHaveBeenCalled()
+ })
+
+ it('rejects a JSON body above the inline-content cap', async () => {
+ const response = await PUT(createRequest({ content: '' }, 70 * 1024 * 1024 + 1), routeContext)
+
+ expect(response.status).toBe(413)
+ await expect(response.json()).resolves.toEqual({
+ error: `Request body exceeds the maximum allowed size of ${70 * 1024 * 1024} bytes`,
+ })
+ expect(mockPerformUpdateContent).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts
index beece206917..a7d4934c783 100644
--- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts
@@ -1,12 +1,20 @@
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
-import { toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
-import { updateWorkspaceFileContentContract } from '@/lib/api/contracts/workspace-files'
-import { parseRequest } from '@/lib/api/server'
+import {
+ updateWorkspaceFileContentContract,
+ workspaceFileParamsSchema,
+} from '@/lib/api/contracts/workspace-files'
+import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
+import {
+ messageForOrchestrationError,
+ statusForOrchestrationError,
+} from '@/lib/core/orchestration/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace'
+import {
+ MAX_WORKSPACE_FILE_INLINE_BODY_BYTES,
+ performUpdateWorkspaceFileContent,
+} from '@/lib/workspace-files/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
export const dynamic = 'force-dynamic'
@@ -19,78 +27,53 @@ const logger = createLogger('WorkspaceFileContentAPI')
*/
export const PUT = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
- try {
- const session = await getSession()
- if (!session?.user?.id) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const parsed = await parseRequest(updateWorkspaceFileContentContract, request, context)
- if (!parsed.success) return parsed.response
- const { id: workspaceId, fileId } = parsed.data.params
- const { content, encoding } = parsed.data.body
-
- const userPermission = await getUserEntityPermissions(
- session.user.id,
- 'workspace',
- workspaceId
- )
- if (userPermission !== 'admin' && userPermission !== 'write') {
- logger.warn(`User ${session.user.id} lacks write permission for workspace ${workspaceId}`)
- return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
- }
-
- const buffer =
- encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf-8')
-
- const maxFileSizeBytes = 50 * 1024 * 1024
- if (buffer.length > maxFileSizeBytes) {
- return NextResponse.json(
- { error: `File size exceeds ${maxFileSizeBytes / 1024 / 1024}MB limit` },
- { status: 413 }
- )
- }
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
- const updatedFile = await updateWorkspaceFileContent(
- workspaceId,
- fileId,
- session.user.id,
- buffer
+ const paramsResult = workspaceFileParamsSchema.safeParse(await context.params)
+ if (!paramsResult.success) {
+ return NextResponse.json(
+ { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') },
+ { status: 400 }
)
+ }
+ const { id: workspaceId, fileId } = paramsResult.data
- logger.info(`Updated content for workspace file: ${updatedFile.name}`)
-
- recordAudit({
- workspaceId,
- actorId: session.user.id,
- actorName: session.user.name,
- actorEmail: session.user.email,
- action: AuditAction.FILE_UPDATED,
- resourceType: AuditResourceType.FILE,
- resourceId: fileId,
- resourceName: updatedFile.name,
- description: `Updated content of file "${updatedFile.name}"`,
- metadata: { contentSize: buffer.length },
- request,
- })
+ const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
+ if (userPermission !== 'admin' && userPermission !== 'write') {
+ logger.warn(`User ${session.user.id} lacks write permission for workspace ${workspaceId}`)
+ return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
+ }
- return NextResponse.json({
- success: true,
- file: updatedFile,
- })
- } catch (error) {
- const errorMessage = toError(error).message || 'Failed to update file content'
- const isNotFound = errorMessage.includes('File not found')
- const isQuotaExceeded = errorMessage.includes('Storage limit exceeded')
- const status = isNotFound ? 404 : isQuotaExceeded ? 402 : 500
+ const parsed = await parseRequest(updateWorkspaceFileContentContract, request, context, {
+ maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES,
+ })
+ if (!parsed.success) return parsed.response
+ const { content, encoding } = parsed.data.body
- if (status === 500) {
- logger.error('Error updating file content:', error)
- } else {
- logger.warn(errorMessage)
- }
+ const result = await performUpdateWorkspaceFileContent({
+ workspaceId,
+ fileId,
+ userId: session.user.id,
+ content,
+ encoding: encoding === 'base64' ? 'base64' : 'utf-8',
+ actorName: session.user.name,
+ actorEmail: session.user.email,
+ request,
+ })
- return NextResponse.json({ success: false, error: errorMessage }, { status })
+ if (!result.success || !result.file) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: messageForOrchestrationError(result, 'Failed to update file content'),
+ },
+ { status: statusForOrchestrationError(result.errorCode) }
+ )
}
+
+ return NextResponse.json({ success: true, file: result.file })
}
)
diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts
index 0d6a09d6361..f5810627a1b 100644
--- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts
@@ -1,23 +1,19 @@
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { getFileShareContract, upsertFileShareContract } from '@/lib/api/contracts/public-shares'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
+import {
+ messageForOrchestrationError,
+ statusForOrchestrationError,
+} from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
- getShareForResource,
- ShareValidationError,
- upsertFileShare,
-} from '@/lib/public-shares/share-manager'
-import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
+ performGetWorkspaceFileShare,
+ performUpsertWorkspaceFileShare,
+} from '@/lib/workspace-files/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
-import {
- PublicFileSharingNotAllowedError,
- validatePublicFileSharing,
-} from '@/ee/access-control/utils/permission-check'
export const dynamic = 'force-dynamic'
@@ -31,40 +27,30 @@ export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
const requestId = generateRequestId()
- try {
- const session = await getSession()
- if (!session?.user?.id) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const parsed = await parseRequest(getFileShareContract, request, context)
- if (!parsed.success) return parsed.response
- const { id: workspaceId, fileId } = parsed.data.params
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
- const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
- if (permission === null) {
- logger.warn(
- `[${requestId}] User ${session.user.id} lacks access to workspace ${workspaceId}`
- )
- return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
- }
+ const parsed = await parseRequest(getFileShareContract, request, context)
+ if (!parsed.success) return parsed.response
+ const { id: workspaceId, fileId } = parsed.data.params
- const file = await getWorkspaceFile(workspaceId, fileId)
- if (!file) {
- return NextResponse.json({ error: 'File not found' }, { status: 404 })
- }
+ const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
+ if (permission === null) {
+ logger.warn(`[${requestId}] User ${session.user.id} lacks access to workspace ${workspaceId}`)
+ return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
+ }
- const share = await getShareForResource('file', fileId)
- return NextResponse.json({ share })
- } catch (error) {
- logger.error(`[${requestId}] Error fetching file share:`, error)
+ const result = await performGetWorkspaceFileShare({ workspaceId, fileId })
+ if (!result.success) {
return NextResponse.json(
- { error: getErrorMessage(error, 'Failed to fetch share') },
- {
- status: 500,
- }
+ { error: messageForOrchestrationError(result, 'Failed to fetch share') },
+ { status: statusForOrchestrationError(result.errorCode) }
)
}
+
+ return NextResponse.json({ share: result.share ?? null })
}
)
@@ -76,89 +62,45 @@ export const PUT = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
const requestId = generateRequestId()
- try {
- const session = await getSession()
- if (!session?.user?.id) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const parsed = await parseRequest(upsertFileShareContract, request, context)
- if (!parsed.success) return parsed.response
- const { id: workspaceId, fileId } = parsed.data.params
- const { isActive, authType, password, allowedEmails, token } = parsed.data.body
-
- const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
- if (permission !== 'admin' && permission !== 'write') {
- logger.warn(
- `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
- )
- return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
- }
-
- const file = await getWorkspaceFile(workspaceId, fileId)
- if (!file) {
- return NextResponse.json({ error: 'File not found' }, { status: 404 })
- }
-
- // Enabling a share is gated by the org's access-control policy (both the
- // master on/off and the per-auth-type allow-list); disabling is always
- // allowed so users can still un-share after the policy is turned on.
- if (isActive) {
- // Validate the auth type that will ACTUALLY be persisted. upsertFileShare
- // falls back to the existing share's authType when none is passed, so a bare
- // re-enable must be checked against that stored mode — not 'public' — or a
- // now-disallowed password/email/sso share could be silently reactivated.
- const existingShare = await getShareForResource('file', fileId)
- const effectiveAuthType = authType ?? existingShare?.authType ?? 'public'
- try {
- await validatePublicFileSharing(session.user.id, workspaceId, effectiveAuthType)
- } catch (error) {
- if (error instanceof PublicFileSharingNotAllowedError) {
- logger.warn(`[${requestId}] Public file sharing disabled for workspace ${workspaceId}`)
- return NextResponse.json({ error: error.message }, { status: 403 })
- }
- throw error
- }
- }
-
- const share = await upsertFileShare({
- workspaceId,
- fileId,
- userId: session.user.id,
- isActive,
- authType,
- password,
- allowedEmails,
- token,
- })
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
- logger.info(`[${requestId}] ${isActive ? 'Enabled' : 'Disabled'} share for file ${fileId}`)
+ const parsed = await parseRequest(upsertFileShareContract, request, context)
+ if (!parsed.success) return parsed.response
+ const { id: workspaceId, fileId } = parsed.data.params
+ const { isActive, authType, password, allowedEmails, token } = parsed.data.body
- recordAudit({
- workspaceId,
- actorId: session.user.id,
- actorName: session.user.name,
- actorEmail: session.user.email,
- action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED,
- resourceType: AuditResourceType.FILE,
- resourceId: fileId,
- resourceName: file.name,
- description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${file.name}"`,
- request,
- })
+ const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
+ if (permission !== 'admin' && permission !== 'write') {
+ logger.warn(
+ `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
+ )
+ return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
+ }
- return NextResponse.json({ share })
- } catch (error) {
- if (error instanceof ShareValidationError) {
- return NextResponse.json({ error: error.message }, { status: 400 })
- }
- logger.error(`[${requestId}] Error updating file share:`, error)
+ const result = await performUpsertWorkspaceFileShare({
+ workspaceId,
+ fileId,
+ userId: session.user.id,
+ isActive,
+ authType,
+ password,
+ allowedEmails,
+ token,
+ actorName: session.user.name,
+ actorEmail: session.user.email,
+ request,
+ })
+
+ if (!result.success || !result.share) {
return NextResponse.json(
- { error: getErrorMessage(error, 'Failed to update share') },
- {
- status: 500,
- }
+ { error: messageForOrchestrationError(result, 'Failed to update share') },
+ { status: statusForOrchestrationError(result.errorCode) }
)
}
+
+ return NextResponse.json({ share: result.share })
}
)
diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts
index 86df6e83f91..ecf7b17b281 100644
--- a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts
@@ -3,12 +3,10 @@ import { type NextRequest, NextResponse } from 'next/server'
import { restoreWorkspaceFileFolderContract } from '@/lib/api/contracts/workspace-file-folders'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
+import { statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
-import {
- performRestoreWorkspaceFileFolder,
- workspaceFilesOrchestrationStatus,
-} from '@/lib/workspace-files/orchestration'
+import { performRestoreWorkspaceFileFolder } from '@/lib/workspace-files/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceFileFolderRestoreAPI')
@@ -38,7 +36,7 @@ export const POST = withRouteHandler(
if (!result.success) {
return NextResponse.json(
{ success: false, error: result.error },
- { status: workspaceFilesOrchestrationStatus(result.errorCode) }
+ { status: statusForOrchestrationError(result.errorCode) }
)
}
const { folder, restoredItems } = result
diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts
index 78232e8a704..079f25e8459 100644
--- a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts
@@ -6,12 +6,12 @@ import {
} from '@/lib/api/contracts/workspace-file-folders'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
+import { statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import {
performDeleteWorkspaceFileItems,
performUpdateWorkspaceFileFolder,
- workspaceFilesOrchestrationStatus,
} from '@/lib/workspace-files/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -47,7 +47,7 @@ export const PATCH = withRouteHandler(
if (!result.success || !result.folder) {
return NextResponse.json(
{ success: false, error: result.error },
- { status: workspaceFilesOrchestrationStatus(result.errorCode) }
+ { status: statusForOrchestrationError(result.errorCode) }
)
}
captureServerEvent(
diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/route.ts
index 02de14dcf1e..ba3180cb609 100644
--- a/apps/sim/app/api/workspaces/[id]/files/folders/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/files/folders/route.ts
@@ -6,13 +6,11 @@ import {
} from '@/lib/api/contracts/workspace-file-folders'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
+import { statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace'
-import {
- performCreateWorkspaceFileFolder,
- workspaceFilesOrchestrationStatus,
-} from '@/lib/workspace-files/orchestration'
+import { performCreateWorkspaceFileFolder } from '@/lib/workspace-files/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceFileFoldersAPI')
@@ -70,7 +68,7 @@ export const POST = withRouteHandler(
if (!result.success || !result.folder) {
return NextResponse.json(
{ success: false, error: result.error },
- { status: workspaceFilesOrchestrationStatus(result.errorCode) }
+ { status: statusForOrchestrationError(result.errorCode) }
)
}
captureServerEvent(
diff --git a/apps/sim/app/api/workspaces/[id]/files/presigned/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/presigned/route.test.ts
deleted file mode 100644
index d69aa933389..00000000000
--- a/apps/sim/app/api/workspaces/[id]/files/presigned/route.test.ts
+++ /dev/null
@@ -1,174 +0,0 @@
-/**
- * @vitest-environment node
- */
-import {
- authMockFns,
- permissionsMock,
- permissionsMockFns,
- storageServiceMock,
- storageServiceMockFns,
-} from '@sim/testing'
-import { NextRequest } from 'next/server'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-
-const {
- mockCheckStorageQuota,
- mockGenerateWorkspaceFileKey,
- mockResolveStorageBillingContext,
- mockUseBlobStorage,
-} = vi.hoisted(() => ({
- mockCheckStorageQuota: vi.fn(),
- mockGenerateWorkspaceFileKey: vi.fn(),
- mockResolveStorageBillingContext: vi.fn(),
- mockUseBlobStorage: { value: false },
-}))
-
-vi.mock('@/lib/billing/storage', () => ({
- checkStorageQuotaForBillingContext: mockCheckStorageQuota,
- resolveStorageBillingContext: mockResolveStorageBillingContext,
-}))
-
-vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
-
-vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
- generateWorkspaceFileKey: mockGenerateWorkspaceFileKey,
-}))
-
-vi.mock('@/lib/uploads/config', () => ({
- getServeStoragePrefix: () => (mockUseBlobStorage.value ? 'blob' : 's3'),
-}))
-
-vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
-
-const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
-const STORAGE_CONTEXT = {
- workspaceId: WS,
- billedAccountUserId: 'workspace-owner',
- billingEntity: { type: 'organization' as const, id: 'workspace-org' },
- plan: 'team_25000',
- customStorageLimitGB: null,
-}
-
-import { POST } from '@/app/api/workspaces/[id]/files/presigned/route'
-
-const params = (id = WS) => ({ params: Promise.resolve({ id }) })
-
-const makeRequest = (body: unknown) =>
- new NextRequest(`http://localhost/api/workspaces/${WS}/files/presigned`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- })
-
-const validBody = {
- fileName: 'video.mp4',
- contentType: 'video/mp4',
- fileSize: 10 * 1024 * 1024,
-}
-
-describe('POST /api/workspaces/[id]/files/presigned', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
- permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
- mockCheckStorageQuota.mockResolvedValue({ allowed: true })
- mockResolveStorageBillingContext.mockResolvedValue(STORAGE_CONTEXT)
- storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true)
- mockGenerateWorkspaceFileKey.mockReturnValue(`workspace/${WS}/123-abc-video.mp4`)
- storageServiceMockFns.mockGeneratePresignedUploadUrl.mockResolvedValue({
- url: 'https://s3/presigned',
- key: `workspace/${WS}/123-abc-video.mp4`,
- uploadHeaders: { 'Content-Type': 'video/mp4' },
- })
- })
-
- it('returns 401 when unauthenticated', async () => {
- authMockFns.mockGetSession.mockResolvedValueOnce(null)
- const res = await POST(makeRequest(validBody), params())
- expect(res.status).toBe(401)
- })
-
- it('returns 403 when user has read-only permission', async () => {
- permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read')
- const res = await POST(makeRequest(validBody), params())
- expect(res.status).toBe(403)
- })
-
- it('returns 400 for missing fileName', async () => {
- const res = await POST(makeRequest({ ...validBody, fileName: '' }), params())
- expect(res.status).toBe(400)
- })
-
- it('returns 400 for negative fileSize', async () => {
- const res = await POST(makeRequest({ ...validBody, fileSize: -1 }), params())
- expect(res.status).toBe(400)
- })
-
- it('accepts fileSize === 0 (empty new files)', async () => {
- const res = await POST(makeRequest({ ...validBody, fileSize: 0 }), params())
- expect(res.status).toBe(200)
- })
-
- it('returns 413 when fileSize exceeds 5 GiB ceiling', async () => {
- const res = await POST(
- makeRequest({ ...validBody, fileSize: 6 * 1024 * 1024 * 1024 }),
- params()
- )
- expect(res.status).toBe(413)
- })
-
- it('returns 413 when storage quota would be exceeded', async () => {
- mockCheckStorageQuota.mockResolvedValueOnce({ allowed: false, error: 'Over quota' })
- const res = await POST(makeRequest(validBody), params())
- const body = await res.json()
- expect(res.status).toBe(413)
- expect(body.error).toBe('Over quota')
- })
-
- it('returns local fallback signal when cloud storage is not configured', async () => {
- storageServiceMockFns.mockHasCloudStorage.mockReturnValueOnce(false)
- const res = await POST(makeRequest(validBody), params())
- const body = await res.json()
- expect(res.status).toBe(200)
- expect(body.directUploadSupported).toBe(false)
- expect(body.presignedUrl).toBe('')
- expect(body.fileInfo.name).toBe('video.mp4')
- expect(storageServiceMockFns.mockGeneratePresignedUploadUrl).not.toHaveBeenCalled()
- })
-
- it('issues a presigned URL bound to the workspace', async () => {
- const res = await POST(makeRequest(validBody), params())
- const body = await res.json()
-
- expect(res.status).toBe(200)
- expect(body.directUploadSupported).toBe(true)
- expect(body.presignedUrl).toBe('https://s3/presigned')
- expect(body.fileInfo.key).toBe(`workspace/${WS}/123-abc-video.mp4`)
- expect(body.fileInfo.path).toContain('?context=workspace')
- expect(body.fileInfo.path).toContain('s3')
- expect(body.uploadHeaders).toEqual({ 'Content-Type': 'video/mp4' })
-
- expect(mockGenerateWorkspaceFileKey).toHaveBeenCalledWith(WS, 'video.mp4')
- expect(mockResolveStorageBillingContext).toHaveBeenCalledWith(WS)
- expect(mockCheckStorageQuota).toHaveBeenCalledWith(STORAGE_CONTEXT, validBody.fileSize)
- expect(storageServiceMockFns.mockGeneratePresignedUploadUrl).toHaveBeenCalledWith(
- expect.objectContaining({
- context: 'workspace',
- userId: 'user-1',
- customKey: `workspace/${WS}/123-abc-video.mp4`,
- metadata: { workspaceId: WS },
- })
- )
- })
-
- it('serves blob path when blob storage is configured', async () => {
- mockUseBlobStorage.value = true
- try {
- const res = await POST(makeRequest(validBody), params())
- const body = await res.json()
- expect(body.fileInfo.path).toContain('/blob/')
- } finally {
- mockUseBlobStorage.value = false
- }
- })
-})
diff --git a/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts b/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts
deleted file mode 100644
index 905ad938d98..00000000000
--- a/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
-import { type NextRequest, NextResponse } from 'next/server'
-import { workspacePresignedUploadContract } from '@/lib/api/contracts/workspace-files'
-import { parseRequest } from '@/lib/api/server'
-import { getSession } from '@/lib/auth'
-import {
- checkStorageQuotaForBillingContext,
- resolveStorageBillingContext,
-} from '@/lib/billing/storage'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { getServeStoragePrefix } from '@/lib/uploads/config'
-import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace'
-import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
-import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service'
-import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types'
-import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
-
-const logger = createLogger('WorkspacePresignedAPI')
-
-/**
- * POST /api/workspaces/[id]/files/presigned
- * Returns a presigned PUT URL for a workspace-scoped object key. The client
- * uploads the bytes directly to S3/Blob, then calls /files/register to
- * insert metadata.
- */
-export const POST = withRouteHandler(
- async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
- const session = await getSession()
- if (!session?.user?.id) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
- const userId = session.user.id
-
- const parsed = await parseRequest(workspacePresignedUploadContract, request, context)
- if (!parsed.success) return parsed.response
- const { params, body } = parsed.data
- const workspaceId = params.id
- const { fileName, contentType, fileSize, folderId } = body
-
- const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
- if (permission !== 'admin' && permission !== 'write') {
- logger.warn(`User ${userId} lacks write permission for ${workspaceId}`)
- return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
- }
-
- if (fileSize > MAX_WORKSPACE_FILE_SIZE) {
- return NextResponse.json(
- { error: `File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes` },
- { status: 413 }
- )
- }
-
- let targetFolderId: string | null
- try {
- targetFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId)
- } catch (error) {
- return NextResponse.json(
- { error: getErrorMessage(error, 'Invalid target folder') },
- { status: 400 }
- )
- }
-
- if (!hasCloudStorage()) {
- logger.info(`Local storage detected, signaling API fallback for ${fileName}`)
- return NextResponse.json({
- fileName,
- presignedUrl: '',
- fileInfo: { path: '', key: '', name: fileName, size: fileSize, type: contentType },
- directUploadSupported: false,
- })
- }
-
- const storageBillingContext = await resolveStorageBillingContext(workspaceId)
- const quotaCheck = await checkStorageQuotaForBillingContext(storageBillingContext, fileSize)
- if (!quotaCheck.allowed) {
- return NextResponse.json(
- { error: quotaCheck.error || 'Storage limit exceeded' },
- { status: 413 }
- )
- }
-
- const key = generateWorkspaceFileKey(workspaceId, fileName)
- const presigned = await generatePresignedUploadUrl({
- fileName,
- contentType,
- fileSize,
- context: 'workspace',
- userId,
- customKey: key,
- expirationSeconds: 3600,
- metadata: { workspaceId, ...(targetFolderId ? { folderId: targetFolderId } : {}) },
- })
-
- const finalPath = `/api/files/serve/${getServeStoragePrefix()}/${encodeURIComponent(key)}?context=workspace`
-
- logger.info(`Issued workspace presigned URL for ${fileName} -> ${key}`)
-
- return NextResponse.json({
- fileName,
- presignedUrl: presigned.url,
- fileInfo: {
- path: finalPath,
- key: presigned.key,
- name: fileName,
- size: fileSize,
- type: contentType,
- },
- uploadHeaders: presigned.uploadHeaders,
- directUploadSupported: true,
- })
- }
-)
diff --git a/apps/sim/app/api/workspaces/[id]/files/register/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/register/route.test.ts
deleted file mode 100644
index cce56f7b8e8..00000000000
--- a/apps/sim/app/api/workspaces/[id]/files/register/route.test.ts
+++ /dev/null
@@ -1,177 +0,0 @@
-/**
- * @vitest-environment node
- */
-import {
- auditMock,
- auditMockFns,
- authMockFns,
- permissionsMock,
- permissionsMockFns,
- posthogServerMock,
- posthogServerMockFns,
-} from '@sim/testing'
-import { NextRequest } from 'next/server'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-
-const { mockRegisterUploadedWorkspaceFile, mockParseWorkspaceFileKey, FileConflictErrorImpl } =
- vi.hoisted(() => {
- class FileConflictErrorImpl extends Error {
- constructor(message: string) {
- super(message)
- this.name = 'FileConflictError'
- }
- }
- return {
- mockRegisterUploadedWorkspaceFile: vi.fn(),
- mockParseWorkspaceFileKey: vi.fn(),
- FileConflictErrorImpl,
- }
- })
-
-vi.mock('@/lib/uploads/contexts/workspace', () => ({
- registerUploadedWorkspaceFile: mockRegisterUploadedWorkspaceFile,
- parseWorkspaceFileKey: mockParseWorkspaceFileKey,
- FileConflictError: FileConflictErrorImpl,
-}))
-
-vi.mock('@/lib/posthog/server', () => posthogServerMock)
-vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
-vi.mock('@sim/audit', () => auditMock)
-
-const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
-const VALID_KEY = `workspace/${WS}/123-abc-video.mp4`
-
-import { POST } from '@/app/api/workspaces/[id]/files/register/route'
-
-const params = (id = WS) => ({ params: Promise.resolve({ id }) })
-
-const makeRequest = (body: unknown) =>
- new NextRequest(`http://localhost/api/workspaces/${WS}/files/register`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- })
-
-const validBody = {
- key: VALID_KEY,
- name: 'video.mp4',
- contentType: 'video/mp4',
-}
-
-describe('POST /api/workspaces/[id]/files/register', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- authMockFns.mockGetSession.mockResolvedValue({
- user: { id: 'user-1', name: 'User One', email: 'u@example.com' },
- })
- permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
- mockParseWorkspaceFileKey.mockImplementation((key: string) => {
- const match = key.match(/^workspace\/([^/]+)\//)
- return match ? match[1] : null
- })
- mockRegisterUploadedWorkspaceFile.mockResolvedValue({
- file: {
- id: 'wf_123',
- name: 'video.mp4',
- size: 10 * 1024 * 1024,
- type: 'video/mp4',
- url: '/api/files/serve/...',
- key: VALID_KEY,
- context: 'workspace',
- },
- created: true,
- })
- })
-
- it('returns 401 when unauthenticated', async () => {
- authMockFns.mockGetSession.mockResolvedValueOnce(null)
- const res = await POST(makeRequest(validBody), params())
- expect(res.status).toBe(401)
- })
-
- it('returns 403 when user lacks write permission', async () => {
- permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read')
- const res = await POST(makeRequest(validBody), params())
- expect(res.status).toBe(403)
- })
-
- it('rejects keys belonging to a different workspace', async () => {
- const otherWsKey = `workspace/00000000-0000-0000-0000-000000000000/123-abc-video.mp4`
- const res = await POST(makeRequest({ ...validBody, key: otherWsKey }), params())
- const body = await res.json()
- expect(res.status).toBe(400)
- expect(body.error).toContain('does not belong')
- expect(mockRegisterUploadedWorkspaceFile).not.toHaveBeenCalled()
- })
-
- it('returns 400 for empty key/name', async () => {
- const res = await POST(makeRequest({ ...validBody, key: '' }), params())
- expect(res.status).toBe(400)
- })
-
- it('returns 404 when storage object is missing', async () => {
- mockRegisterUploadedWorkspaceFile.mockRejectedValueOnce(
- new Error('Uploaded object not found in storage')
- )
- const res = await POST(makeRequest(validBody), params())
- expect(res.status).toBe(404)
- })
-
- it('returns 409 on duplicate file conflict', async () => {
- mockRegisterUploadedWorkspaceFile.mockRejectedValueOnce(new FileConflictErrorImpl('video.mp4'))
- const res = await POST(makeRequest(validBody), params())
- const body = await res.json()
- expect(res.status).toBe(409)
- expect(body.isDuplicate).toBe(true)
- })
-
- it('skips audit + analytics on idempotent re-register (created=false)', async () => {
- mockRegisterUploadedWorkspaceFile.mockResolvedValueOnce({
- file: {
- id: 'wf_123',
- name: 'video.mp4',
- size: 10 * 1024 * 1024,
- type: 'video/mp4',
- url: '/api/files/serve/...',
- key: VALID_KEY,
- context: 'workspace',
- },
- created: false,
- })
-
- const res = await POST(makeRequest(validBody), params())
- expect(res.status).toBe(200)
- expect(posthogServerMockFns.mockCaptureServerEvent).not.toHaveBeenCalled()
- expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled()
- })
-
- it('finalizes upload, records audit and analytics', async () => {
- const res = await POST(makeRequest(validBody), params())
- const body = await res.json()
-
- expect(res.status).toBe(200)
- expect(body.success).toBe(true)
- expect(body.file).toMatchObject({ id: 'wf_123', key: VALID_KEY })
-
- expect(mockRegisterUploadedWorkspaceFile).toHaveBeenCalledWith({
- workspaceId: WS,
- userId: 'user-1',
- key: VALID_KEY,
- originalName: 'video.mp4',
- contentType: 'video/mp4',
- })
-
- expect(posthogServerMockFns.mockCaptureServerEvent).toHaveBeenCalledWith(
- 'user-1',
- 'file_uploaded',
- expect.objectContaining({ workspace_id: WS, file_type: 'video/mp4' }),
- expect.any(Object)
- )
- expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith(
- expect.objectContaining({
- actorId: 'user-1',
- workspaceId: WS,
- })
- )
- })
-})
diff --git a/apps/sim/app/api/workspaces/[id]/files/register/route.ts b/apps/sim/app/api/workspaces/[id]/files/register/route.ts
deleted file mode 100644
index 4ed0b90c285..00000000000
--- a/apps/sim/app/api/workspaces/[id]/files/register/route.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
-import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
-import { type NextRequest, NextResponse } from 'next/server'
-import { registerWorkspaceFileContract } from '@/lib/api/contracts/workspace-files'
-import { parseRequest } from '@/lib/api/server'
-import { getSession } from '@/lib/auth'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { captureServerEvent } from '@/lib/posthog/server'
-import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify'
-import {
- FileConflictError,
- parseWorkspaceFileKey,
- registerUploadedWorkspaceFile,
-} from '@/lib/uploads/contexts/workspace'
-import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
-
-const logger = createLogger('WorkspaceRegisterAPI')
-
-/**
- * POST /api/workspaces/[id]/files/register
- * Finalize a direct-to-storage upload by inserting metadata, updating quota,
- * and recording an audit log. Validates the storage key belongs to the
- * caller's workspace to prevent cross-tenant key smuggling.
- */
-export const POST = withRouteHandler(
- async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
- const session = await getSession()
- if (!session?.user?.id) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
- const userId = session.user.id
-
- const parsed = await parseRequest(registerWorkspaceFileContract, request, context)
- if (!parsed.success) return parsed.response
- const { params, body } = parsed.data
- const workspaceId = params.id
- const { key, name, contentType, folderId } = body
-
- const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
- if (permission !== 'admin' && permission !== 'write') {
- logger.warn(`User ${userId} lacks write permission for ${workspaceId}`)
- return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
- }
-
- if (parseWorkspaceFileKey(key) !== workspaceId) {
- logger.warn(`Key ${key} does not belong to workspace ${workspaceId}`)
- return NextResponse.json(
- { error: 'Storage key does not belong to this workspace' },
- { status: 400 }
- )
- }
-
- try {
- const { file: userFile, created } = await registerUploadedWorkspaceFile({
- workspaceId,
- userId,
- key,
- originalName: name,
- contentType,
- folderId,
- })
-
- if (created) {
- logger.info(`Registered direct upload ${name} -> ${key}`)
-
- await notifyWorkspaceFilesChanged(workspaceId)
-
- captureServerEvent(
- userId,
- 'file_uploaded',
- { workspace_id: workspaceId, file_type: contentType },
- { groups: { workspace: workspaceId } }
- )
-
- recordAudit({
- workspaceId,
- actorId: userId,
- actorName: session.user.name,
- actorEmail: session.user.email,
- action: AuditAction.FILE_UPLOADED,
- resourceType: AuditResourceType.FILE,
- resourceId: userFile.id,
- resourceName: name,
- description: `Uploaded file "${name}"`,
- metadata: { fileSize: userFile.size, fileType: contentType },
- request,
- })
- } else {
- logger.info(`Idempotent re-register for existing upload ${name} -> ${key}`)
- }
-
- return NextResponse.json({ success: true, file: userFile })
- } catch (error) {
- logger.error('Failed to register workspace file:', error)
-
- const errorMessage = getErrorMessage(error, 'Failed to register file')
- const isDuplicate =
- error instanceof FileConflictError || errorMessage.includes('already exists')
- const isMissing = errorMessage.includes('not found in storage')
-
- const status = isDuplicate ? 409 : isMissing ? 404 : 500
- return NextResponse.json({ success: false, error: errorMessage, isDuplicate }, { status })
- }
- }
-)
diff --git a/apps/sim/app/api/workspaces/[id]/files/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/route.test.ts
index 9d71894ecfa..7ce4f9f1a4c 100644
--- a/apps/sim/app/api/workspaces/[id]/files/route.test.ts
+++ b/apps/sim/app/api/workspaces/[id]/files/route.test.ts
@@ -1,143 +1,253 @@
/**
- * Tests for the workspace files upload route's bounded multipart read.
- *
* @vitest-environment node
*/
-import { authMockFns, permissionsMock, permissionsMockFns, posthogServerMock } from '@sim/testing'
+import { authMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockUploadWorkspaceFile, mockGetWorkspaceShares, mockRecordAudit } = vi.hoisted(() => ({
- mockUploadWorkspaceFile: vi.fn(),
+const {
+ mockGetUserEntityPermissions,
+ mockGetWorkspaceShares,
+ mockListWorkspaceFiles,
+ mockPerformCreateWorkspaceFile,
+} = vi.hoisted(() => ({
+ mockGetUserEntityPermissions: vi.fn(),
mockGetWorkspaceShares: vi.fn(),
- mockRecordAudit: vi.fn(),
+ mockListWorkspaceFiles: vi.fn(),
+ mockPerformCreateWorkspaceFile: vi.fn(),
}))
-vi.mock('@/lib/uploads/contexts/workspace', () => ({
- uploadWorkspaceFile: mockUploadWorkspaceFile,
- FileConflictError: class FileConflictError extends Error {},
+vi.mock('@/lib/public-shares/share-manager', () => ({
+ getWorkspaceShares: mockGetWorkspaceShares,
}))
-vi.mock('@/lib/uploads/shared/types', async (importOriginal) => {
- const actual = await importOriginal()
- return {
- ...actual,
- MAX_WORKSPACE_FORMDATA_FILE_SIZE: 1024,
- }
-})
+vi.mock('@/lib/uploads/contexts/workspace', () => ({
+ listWorkspaceFiles: mockListWorkspaceFiles,
+}))
-vi.mock('@/lib/public-shares/share-manager', () => ({
- getWorkspaceShares: mockGetWorkspaceShares,
+vi.mock('@/lib/workspace-files/orchestration', () => ({
+ MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024,
+ performCreateWorkspaceFile: mockPerformCreateWorkspaceFile,
}))
-vi.mock('@/lib/posthog/server', () => posthogServerMock)
-vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
+vi.mock('@/lib/workspaces/permissions/utils', () => ({
+ getUserEntityPermissions: mockGetUserEntityPermissions,
+}))
vi.mock('@/app/api/workflows/utils', () => ({
verifyWorkspaceMembership: vi.fn().mockResolvedValue('write'),
}))
-vi.mock('@sim/audit', () => ({
- recordAudit: mockRecordAudit,
- AuditAction: { FILE_UPLOADED: 'file_uploaded' },
- AuditResourceType: { FILE: 'file' },
-}))
-
-const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
import { POST } from '@/app/api/workspaces/[id]/files/route'
-const routeContext = { params: Promise.resolve({ id: WS }) }
-
-function buildFormData(file: File): FormData {
- const formData = new FormData()
- formData.append('file', file)
- return formData
+const WORKSPACE_ID = '7727ef3f-8cf6-4686-b063-2bb006a10785'
+const USER = { id: 'user-1', name: 'Test User', email: 'test@sim.ai' }
+const CREATED_FILE = {
+ id: 'wf_created',
+ workspaceId: WORKSPACE_ID,
+ name: 'untitled.md',
+ key: `workspace/${WORKSPACE_ID}/untitled.md`,
+ path: '/api/files/serve/untitled.md?context=workspace',
+ size: 0,
+ type: 'text/markdown',
+ uploadedBy: USER.id,
+ folderId: null,
+ folderPath: null,
+ deletedAt: null,
+ uploadedAt: new Date('2026-08-04T00:00:00.000Z'),
+ updatedAt: new Date('2026-08-04T00:00:00.000Z'),
}
-/**
- * Builds a pull-based stream that emits fixed-size chunks on demand, so the
- * size-capped reader's `reader.cancel()` simply stops future `pull` calls
- * instead of racing an external (e.g. undici FormData) chunk producer.
- */
-function makeChunkedOverLimitBody(
- chunkBytes: number,
- chunkCount: number
-): ReadableStream {
- let emitted = 0
- return new ReadableStream({
- pull(controller) {
- if (emitted >= chunkCount) {
- controller.close()
- return
- }
- emitted++
- controller.enqueue(new Uint8Array(chunkBytes))
- },
+const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID }) }
+
+function createRequest(body: unknown): NextRequest {
+ return new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: typeof body === 'string' ? body : JSON.stringify(body),
})
}
-describe('workspace files upload route', () => {
+describe('POST /api/workspaces/[id]/files', () => {
beforeEach(() => {
vi.clearAllMocks()
- authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
- permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
+ authMockFns.mockGetSession.mockResolvedValue({ user: USER })
+ mockGetUserEntityPermissions.mockResolvedValue('write')
mockGetWorkspaceShares.mockResolvedValue(new Map())
- mockUploadWorkspaceFile.mockResolvedValue({
- id: 'file-1',
- name: 'file.txt',
- url: 'https://example.com/file.txt',
- size: 11,
- type: 'text/plain',
- })
+ mockListWorkspaceFiles.mockResolvedValue([])
+ mockPerformCreateWorkspaceFile.mockResolvedValue({ success: true, file: CREATED_FILE })
})
- it('rejects a declared content-length above the limit before reading the body', async () => {
- const formData = buildFormData(new File(['x'.repeat(10)], 'file.txt', { type: 'text/plain' }))
- const req = new NextRequest(`http://localhost:3000/api/workspaces/${WS}/files`, {
- method: 'POST',
- headers: { 'content-length': String(10 * 1024 * 1024) },
- body: formData,
- })
+ it('authenticates before parsing an invalid request body', async () => {
+ authMockFns.mockGetSession.mockResolvedValue(null)
+
+ const response = await POST(createRequest('{not-json'), routeContext)
+
+ expect(response.status).toBe(401)
+ await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' })
+ expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
+ expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled()
+ })
- const response = await POST(req, routeContext)
- const data = await response.json()
+ it('authorizes the workspace before parsing the request body', async () => {
+ mockGetUserEntityPermissions.mockResolvedValue('read')
- expect(response.status).toBe(413)
- expect(data.error).toContain('exceeds maximum size')
- expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
+ const response = await POST(createRequest({ content: 'missing a name' }), routeContext)
+
+ expect(response.status).toBe(403)
+ await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' })
+ expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER.id, 'workspace', WORKSPACE_ID)
+ expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled()
})
- it('rejects a chunked body without content-length once the streamed size trips the cap', async () => {
- const body = makeChunkedOverLimitBody(64 * 1024, 32)
- const req = new NextRequest(`http://localhost:3000/api/workspaces/${WS}/files`, {
- method: 'POST',
- body,
- // @ts-expect-error - duplex is required by undici for streamed bodies but missing from NextRequestInit types
- duplex: 'half',
+ it('rejects an invalid body after workspace authorization', async () => {
+ const response = await POST(createRequest({ content: 'missing a name' }), routeContext)
+ const body = await response.json()
+
+ expect(response.status).toBe(400)
+ expect(body.error).toBe('Validation error')
+ expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER.id, 'workspace', WORKSPACE_ID)
+ expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled()
+ })
+
+ it.each(['read', null])(
+ 'requires write or admin permission (%s is rejected)',
+ async (permission) => {
+ mockGetUserEntityPermissions.mockResolvedValue(permission)
+
+ const response = await POST(createRequest({ name: 'untitled.md' }), routeContext)
+
+ expect(response.status).toBe(403)
+ await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' })
+ expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled()
+ }
+ )
+
+ it.each(['write', 'admin'])(
+ 'creates an empty file with defaults for %s users',
+ async (permission) => {
+ mockGetUserEntityPermissions.mockResolvedValue(permission)
+ const request = createRequest({ name: 'untitled.md' })
+
+ const response = await POST(request, routeContext)
+ const body = await response.json()
+
+ expect(response.status).toBe(201)
+ expect(body).toMatchObject({ success: true, file: { id: CREATED_FILE.id } })
+ expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledTimes(1)
+ const params = mockPerformCreateWorkspaceFile.mock.calls[0][0]
+ expect(params).toMatchObject({
+ workspaceId: WORKSPACE_ID,
+ userId: USER.id,
+ actorName: USER.name,
+ actorEmail: USER.email,
+ name: 'untitled.md',
+ contentType: 'text/markdown',
+ exactName: false,
+ })
+ expect(params.folderId).toBeUndefined()
+ expect(params.content).toEqual(Buffer.alloc(0))
+ expect(params.request).toBe(request)
+ }
+ )
+
+ it('decodes initialized base64 content and preserves folder and content type', async () => {
+ const content = Buffer.from([0, 1, 2, 255])
+ const request = createRequest({
+ name: 'data.bin',
+ contentType: 'application/octet-stream',
+ folderId: 'folder-1',
+ content: content.toString('base64'),
+ encoding: 'base64',
+ })
+ mockPerformCreateWorkspaceFile.mockResolvedValue({
+ success: true,
+ file: {
+ ...CREATED_FILE,
+ name: 'data.bin',
+ type: 'application/octet-stream',
+ size: content.length,
+ folderId: 'folder-1',
+ },
})
- expect(req.headers.get('content-length')).toBeNull()
- const response = await POST(req, routeContext)
- const data = await response.json()
+ const response = await POST(request, routeContext)
+
+ expect(response.status).toBe(201)
+ expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workspaceId: WORKSPACE_ID,
+ name: 'data.bin',
+ contentType: 'application/octet-stream',
+ folderId: 'folder-1',
+ content,
+ exactName: false,
+ })
+ )
+ })
+
+ it('rejects malformed base64 after authorization and before orchestration', async () => {
+ const response = await POST(
+ createRequest({ name: 'data.bin', content: 'not-base64!', encoding: 'base64' }),
+ routeContext
+ )
+
+ expect(response.status).toBe(400)
+ await expect(response.json()).resolves.toMatchObject({ error: 'Validation error' })
+ expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER.id, 'workspace', WORKSPACE_ID)
+ expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled()
+ })
+
+ it('accepts empty base64 as a zero-byte file', async () => {
+ const response = await POST(
+ createRequest({ name: 'empty.bin', content: '', encoding: 'base64' }),
+ routeContext
+ )
- expect(response.status).toBe(413)
- expect(data.error).toContain('exceeds maximum size')
- expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
+ expect(response.status).toBe(201)
+ expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith(
+ expect.objectContaining({ content: Buffer.alloc(0) })
+ )
})
- it('uploads a normal, well-under-limit file successfully', async () => {
- const file = new File(['hello world'], 'file.txt', { type: 'text/plain' })
- const formData = buildFormData(file)
- const req = new NextRequest(`http://localhost:3000/api/workspaces/${WS}/files`, {
- method: 'POST',
- headers: { 'content-length': '512' },
- body: formData,
+ it.each([
+ ['validation', 400, 'Invalid file name'],
+ ['not_found', 404, 'Target folder not found'],
+ ['conflict', 409, 'A file with this name already exists'],
+ ['payload_too_large', 413, 'File size exceeds 50MB limit'],
+ ] as const)('maps a %s orchestration failure to %i', async (errorCode, expectedStatus, error) => {
+ mockPerformCreateWorkspaceFile.mockResolvedValue({ success: false, error, errorCode })
+
+ const response = await POST(createRequest({ name: 'untitled.md' }), routeContext)
+
+ expect(response.status).toBe(expectedStatus)
+ await expect(response.json()).resolves.toEqual({ success: false, error })
+ })
+
+ it('does not expose an internal orchestration error', async () => {
+ mockPerformCreateWorkspaceFile.mockResolvedValue({
+ success: false,
+ error: 'update workspace_files set ... failed',
+ errorCode: 'internal',
+ })
+
+ const response = await POST(createRequest({ name: 'untitled.md' }), routeContext)
+
+ expect(response.status).toBe(500)
+ await expect(response.json()).resolves.toEqual({
+ success: false,
+ error: 'Failed to create file',
})
+ })
- const response = await POST(req, routeContext)
- const data = await response.json()
+ it('maps an unexpected throw to a 500 response', async () => {
+ mockPerformCreateWorkspaceFile.mockRejectedValue(new Error('storage unavailable'))
- expect(response.status).toBe(200)
- expect(data.success).toBe(true)
- expect(mockUploadWorkspaceFile).toHaveBeenCalledTimes(1)
+ const response = await POST(createRequest({ name: 'untitled.md' }), routeContext)
+
+ expect(response.status).toBe(500)
+ await expect(response.json()).resolves.toEqual({
+ success: false,
+ error: 'Failed to create file',
+ })
})
})
diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts
index b5d1d4f1fdc..9a370fb4f94 100644
--- a/apps/sim/app/api/workspaces/[id]/files/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/files/route.ts
@@ -1,28 +1,26 @@
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import {
+ createWorkspaceFileContract,
listWorkspaceFilesQuerySchema,
workspaceFilesParamsSchema,
} from '@/lib/api/contracts/workspace-files'
-import { getValidationErrorMessage } from '@/lib/api/server'
+import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
-import { generateRequestId } from '@/lib/core/utils/request'
import {
- isPayloadSizeLimitError,
- MAX_MULTIPART_OVERHEAD_BYTES,
- readFormDataWithLimit,
-} from '@/lib/core/utils/stream-limits'
+ messageForOrchestrationError,
+ statusForOrchestrationError,
+} from '@/lib/core/orchestration/types'
+import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { captureServerEvent } from '@/lib/posthog/server'
import { getWorkspaceShares } from '@/lib/public-shares/share-manager'
+import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace'
+import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
import {
- FileConflictError,
- listWorkspaceFiles,
- uploadWorkspaceFile,
-} from '@/lib/uploads/contexts/workspace'
-import { MAX_WORKSPACE_FORMDATA_FILE_SIZE } from '@/lib/uploads/shared/types'
+ MAX_WORKSPACE_FILE_INLINE_BODY_BYTES,
+ performCreateWorkspaceFile,
+} from '@/lib/workspace-files/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
@@ -101,19 +99,11 @@ export const GET = withRouteHandler(
/**
* POST /api/workspaces/[id]/files
- * Upload a new file to workspace storage (requires write permission)
+ * Create an authored workspace file (requires write permission)
*/
export const POST = withRouteHandler(
- async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
+ async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
- const paramsResult = workspaceFilesParamsSchema.safeParse(await params)
- if (!paramsResult.success) {
- return NextResponse.json(
- { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') },
- { status: 400 }
- )
- }
- const { id: workspaceId } = paramsResult.data
try {
const session = await getSession()
@@ -121,7 +111,15 @@ export const POST = withRouteHandler(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
- // Check workspace permissions (requires write)
+ const paramsResult = workspaceFilesParamsSchema.safeParse(await context.params)
+ if (!paramsResult.success) {
+ return NextResponse.json(
+ { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') },
+ { status: 400 }
+ )
+ }
+ const { id: workspaceId } = paramsResult.data
+
const userPermission = await getUserEntityPermissions(
session.user.id,
'workspace',
@@ -134,93 +132,45 @@ export const POST = withRouteHandler(
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
- let formData: FormData
- try {
- formData = await readFormDataWithLimit(request, {
- maxBytes: MAX_WORKSPACE_FORMDATA_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES,
- label: 'workspace file upload body',
- })
- } catch (error) {
- if (isPayloadSizeLimitError(error)) {
- return NextResponse.json({ error: error.message }, { status: 413 })
- }
- return NextResponse.json(
- { error: 'Request body must be valid multipart form data' },
- { status: 400 }
- )
- }
- const rawFile = formData.get('file')
- const rawFolderId = formData.get('folderId')
- const folderId =
- typeof rawFolderId === 'string' && rawFolderId.length > 0 ? rawFolderId : null
-
- if (!rawFile || !(rawFile instanceof File)) {
- return NextResponse.json({ error: 'No file provided' }, { status: 400 })
- }
-
- const fileName = rawFile.name || 'untitled.md'
-
- if (rawFile.size > MAX_WORKSPACE_FORMDATA_FILE_SIZE) {
- return NextResponse.json(
- {
- error: `File size exceeds maximum of ${MAX_WORKSPACE_FORMDATA_FILE_SIZE} bytes (${(rawFile.size / (1024 * 1024)).toFixed(2)}MB)`,
- },
- { status: 413 }
- )
- }
-
- const buffer = Buffer.from(await rawFile.arrayBuffer())
-
- const userFile = await uploadWorkspaceFile(
- workspaceId,
- session.user.id,
- buffer,
- fileName,
- rawFile.type || 'application/octet-stream',
- { folderId }
- )
-
- logger.info(`[${requestId}] Uploaded workspace file: ${fileName}`)
-
- captureServerEvent(
- session.user.id,
- 'file_uploaded',
- { workspace_id: workspaceId, file_type: rawFile.type || 'application/octet-stream' },
- { groups: { workspace: workspaceId } }
- )
+ const parsed = await parseRequest(createWorkspaceFileContract, request, context, {
+ maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES,
+ })
+ if (!parsed.success) return parsed.response
+ const { name, contentType, folderId, content, encoding } = parsed.data.body
- recordAudit({
+ const result = await performCreateWorkspaceFile({
workspaceId,
- actorId: session.user.id,
+ userId: session.user.id,
+ name,
+ contentType: contentType ?? getMimeTypeFromExtension(getFileExtension(name)),
+ folderId,
+ content: Buffer.from(content, encoding),
+ exactName: false,
actorName: session.user.name,
actorEmail: session.user.email,
- action: AuditAction.FILE_UPLOADED,
- resourceType: AuditResourceType.FILE,
- resourceId: userFile.id,
- resourceName: fileName,
- description: `Uploaded file "${fileName}"`,
- metadata: { fileSize: rawFile.size, fileType: rawFile.type || 'application/octet-stream' },
request,
})
+ if (!result.success || !result.file) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: messageForOrchestrationError(result, 'Failed to create file'),
+ },
+ { status: statusForOrchestrationError(result.errorCode) }
+ )
+ }
- return NextResponse.json({
- success: true,
- file: userFile,
- })
+ logger.info(`[${requestId}] Created workspace file: ${result.file.name}`)
+ return NextResponse.json({ success: true, file: result.file }, { status: 201 })
} catch (error) {
- logger.error(`[${requestId}] Error uploading workspace file:`, error)
-
- const errorMessage = getErrorMessage(error, 'Failed to upload file')
- const isDuplicate =
- error instanceof FileConflictError || errorMessage.includes('already exists')
+ logger.error(`[${requestId}] Error creating workspace file:`, error)
return NextResponse.json(
{
success: false,
- error: errorMessage,
- isDuplicate,
+ error: 'Failed to create file',
},
- { status: isDuplicate ? 409 : 500 }
+ { status: 500 }
)
}
}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts
index b91d1b99318..efd99ab7b40 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts
@@ -2,14 +2,13 @@
import { useCallback, useEffect, useRef } from 'react'
import { toast } from '@sim/emcn'
-import { generateId } from '@sim/utils/id'
import { useRouter } from 'next/navigation'
import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { useImportFileAsTable } from '@/hooks/queries/tables'
import { useImportTrayStore } from '@/stores/table/import-tray/store'
-export type CsvImportFileDescriptor = Pick
+export type CsvImportFileDescriptor = Pick
/**
* Wires the "Import as a table" affordance for a capped CSV preview. When the preview is
@@ -32,10 +31,7 @@ export function useCsvTruncationImport(
const importAsTable = useCallback(() => {
if (importingRef.current) return
importingRef.current = true
- const pendingId = `pending_${generateId()}`
- useImportTrayStore
- .getState()
- .startUpload({ uploadId: pendingId, workspaceId, title: file.name })
+ let importId: string | null = null
toast.success(`Importing "${file.name}" as a table`, {
description: 'This runs in the background.',
action: {
@@ -44,17 +40,29 @@ export function useCsvTruncationImport(
},
})
importFile.mutate(
- { workspaceId, fileKey: file.key, fileName: file.name },
+ {
+ workspaceId,
+ fileId: file.id,
+ fileName: file.name,
+ onCreated: (createdImportId) => {
+ importId = createdImportId
+ useImportTrayStore.getState().startUpload({
+ uploadId: createdImportId,
+ workspaceId,
+ title: file.name,
+ })
+ },
+ },
{
onSettled: () => {
importingRef.current = false
- useImportTrayStore.getState().endUpload(pendingId)
+ if (importId) useImportTrayStore.getState().endUpload(importId)
},
}
)
// importFile.mutate and router are stable references
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [workspaceId, file.key, file.name])
+ }, [workspaceId, file.id, file.key, file.name])
// Surface the cap as a warning toast with an import action, once per file.
const notifiedKeyRef = useRef(null)
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx
index 006012191ac..33effd87945 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx
@@ -288,6 +288,7 @@ const ReadOnlyTextPreview = memo(function ReadOnlyTextPreview({
mimeType={file.type}
filename={file.name}
workspaceId={workspaceId}
+ fileId={file.id}
fileKey={file.key}
readOnly
/>
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx
index 764349c42ad..4dbb0528483 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx
@@ -42,6 +42,7 @@ interface PreviewPanelProps {
mimeType: string | null
filename: string
workspaceId: string
+ fileId: string
fileKey: string
isStreaming?: boolean
/**
@@ -57,6 +58,7 @@ export const PreviewPanel = memo(function PreviewPanel({
mimeType,
filename,
workspaceId,
+ fileId,
fileKey,
isStreaming,
readOnly,
@@ -69,7 +71,7 @@ export const PreviewPanel = memo(function PreviewPanel({
)
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx
index 966b1943e8c..b2aad750049 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx
@@ -680,6 +680,7 @@ export const TextEditor = memo(function TextEditor({
mimeType={file.type}
filename={file.name}
workspaceId={workspaceId}
+ fileId={file.id}
fileKey={file.key}
isStreaming={isStreaming}
/>
diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
index 736ec48089e..d772c13c800 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
@@ -112,6 +112,7 @@ import {
type WorkspaceFileFolderApi,
} from '@/hooks/queries/workspace-file-folders'
import {
+ useCreateWorkspaceFile,
useDeleteWorkspaceFile,
useRenameWorkspaceFile,
useUploadWorkspaceFile,
@@ -256,6 +257,7 @@ export function Files() {
return map
}, [members])
const uploadFile = useUploadWorkspaceFile()
+ const createWorkspaceFile = useCreateWorkspaceFile()
const notifyLimit = useLimitUpgradeToast()
const deleteFile = useDeleteWorkspaceFile()
const renameFile = useRenameWorkspaceFile()
@@ -1313,15 +1315,13 @@ export function Files() {
const name = uniqueMarkdownName(DEFAULT_UNTITLED_NAME, existingNames)
const mimeType = getMimeTypeFromExtension('md')
- const blob = new Blob([''], { type: mimeType })
- const file = new File([blob], name, { type: mimeType })
- const result = await uploadFile.mutateAsync({
+ const result = await createWorkspaceFile.mutateAsync({
workspaceId,
- file,
- folderId: currentFolderId,
- skipToast: true,
+ name,
+ contentType: mimeType,
+ folderId: currentFolderId ?? undefined,
})
- const fileId = result.file?.id
+ const fileId = result.file.id
if (fileId) {
justCreatedFileIdRef.current = fileId
const params = new URLSearchParams({ new: '1' })
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
index 89f35608f31..0d773da797c 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
@@ -60,7 +60,7 @@ import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowI
import { useFolders } from '@/hooks/queries/folders'
import { useLogDetail } from '@/hooks/queries/logs'
import { useScheduleById } from '@/hooks/queries/schedules'
-import { downloadTableExport } from '@/hooks/queries/tables'
+import { exportTable } from '@/hooks/queries/tables'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
@@ -344,13 +344,7 @@ export function ResourceActions({ workspaceId, resource }: ResourceActionsProps)
)
case 'table':
- return (
-
- )
+ return
case 'log':
return
case 'scheduledtask':
@@ -508,10 +502,9 @@ const tableLogger = createLogger('EmbeddedTableActions')
interface EmbeddedTableActionsProps {
workspaceId: string
tableId: string
- tableName: string
}
-function EmbeddedTableActions({ workspaceId, tableId, tableName }: EmbeddedTableActionsProps) {
+function EmbeddedTableActions({ workspaceId, tableId }: EmbeddedTableActionsProps) {
const router = useRouter()
const handleOpenTable = () => {
@@ -520,7 +513,7 @@ function EmbeddedTableActions({ workspaceId, tableId, tableName }: EmbeddedTable
const handleExport = async () => {
try {
- await downloadTableExport(tableId, tableName)
+ await exportTable(workspaceId, tableId)
} catch (err) {
tableLogger.error('Failed to export table:', err)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx
index cfdc62d32af..ba29e66597d 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx
@@ -15,6 +15,10 @@ import {
import { createLogger } from '@sim/logger'
import { RotateCcw, X } from 'lucide-react'
import { useParams } from 'next/navigation'
+import {
+ assertMultiFileUploadAdmission,
+ MultiFileUploadAdmissionError,
+} from '@/lib/uploads/client/admission'
import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils'
import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
@@ -77,11 +81,11 @@ export function AddDocumentsModal({
}
const processFiles = (selectedFiles: File[]) => {
- setFileError(null)
-
if (!selectedFiles || selectedFiles.length === 0) return
try {
+ assertMultiFileUploadAdmission(selectedFiles, { existingFiles: files })
+ setFileError(null)
const newFiles: File[] = []
let hasError = false
@@ -100,6 +104,10 @@ export function AddDocumentsModal({
setFiles((prev) => [...prev, ...newFiles])
}
} catch (error) {
+ if (error instanceof MultiFileUploadAdmissionError) {
+ setFileError(error.message)
+ return
+ }
logger.error('Error processing files:', error)
setFileError('An error occurred while processing files. Please try again.')
}
@@ -156,7 +164,7 @@ export function AddDocumentsModal({
accept={ACCEPT_ATTRIBUTE}
multiple
onChange={processFiles}
- description='PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSONL (max 100MB each)'
+ description='PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSONL (max 20 files, 100MB each, 500MB total)'
error={fileError}
/>
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx
index 5364d754044..88c4b5c8603 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx
@@ -27,6 +27,10 @@ import { type FieldErrors, useForm } from 'react-hook-form'
import { z } from 'zod'
import type { StrategyOptions } from '@/lib/chunkers/types'
import { KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH } from '@/lib/knowledge/constants'
+import {
+ assertMultiFileUploadAdmission,
+ MultiFileUploadAdmissionError,
+} from '@/lib/uploads/client/admission'
import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils'
import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
@@ -202,11 +206,11 @@ export const CreateBaseModal = memo(function CreateBaseModal({
}, [open, reset])
const processFiles = (selectedFiles: File[]) => {
- setFileError(null)
-
if (!selectedFiles || selectedFiles.length === 0) return
try {
+ assertMultiFileUploadAdmission(selectedFiles, { existingFiles: files })
+ setFileError(null)
const newFiles: File[] = []
let hasError = false
@@ -225,6 +229,10 @@ export const CreateBaseModal = memo(function CreateBaseModal({
setFiles((prev) => [...prev, ...newFiles])
}
} catch (error) {
+ if (error instanceof MultiFileUploadAdmissionError) {
+ setFileError(error.message)
+ return
+ }
logger.error('Error processing files:', error)
setFileError('An error occurred while processing files. Please try again.')
}
@@ -474,7 +482,7 @@ export const CreateBaseModal = memo(function CreateBaseModal({
accept={ACCEPT_ATTRIBUTE}
multiple
onChange={processFiles}
- description='PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSONL (max 100MB each)'
+ description='PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSONL (max 20 files, 100MB each, 500MB total)'
error={fileError}
/>
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.test.tsx
new file mode 100644
index 00000000000..c14cc716e7c
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.test.tsx
@@ -0,0 +1,88 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+const { mockInvalidateQueries, mockUploadKnowledgeDocumentSession } = vi.hoisted(() => ({
+ mockInvalidateQueries: vi.fn(),
+ mockUploadKnowledgeDocumentSession: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }),
+}))
+
+vi.mock('@/lib/uploads/client/session-upload', () => ({
+ uploadKnowledgeDocumentSession: mockUploadKnowledgeDocumentSession,
+}))
+
+import { MULTI_FILE_UPLOAD_MAX_FILE_BYTES } from '@/lib/uploads/client/admission'
+import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
+
+interface HookHarness {
+ result: () => ReturnType
+ unmount: () => void
+}
+
+function renderKnowledgeUploadHook(onError: ReturnType): HookHarness {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ const root: Root = createRoot(document.createElement('div'))
+ let latest: ReturnType
+
+ function Probe() {
+ latest = useKnowledgeUpload({ workspaceId: 'workspace-1', onError })
+ return null
+ }
+
+ act(() => root.render())
+ return {
+ result: () => latest,
+ unmount: () => act(() => root.unmount()),
+ }
+}
+
+function sizedFile(name: string, size: number): File {
+ const file = new File([], name, { type: 'application/octet-stream' })
+ Object.defineProperty(file, 'size', { value: size })
+ return file
+}
+
+describe('useKnowledgeUpload admission', () => {
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('rejects aggregate bytes before allocating upload progress or sessions', async () => {
+ const onError = vi.fn()
+ const { result, unmount } = renderKnowledgeUploadHook(onError)
+ const files = Array.from({ length: 6 }, (_, index) =>
+ sizedFile(`file-${index}.bin`, MULTI_FILE_UPLOAD_MAX_FILE_BYTES)
+ )
+
+ await act(async () => {
+ await expect(result().uploadFiles(files, 'kb-1')).rejects.toMatchObject({
+ code: 'UPLOAD_TOTAL_SIZE_EXCEEDED',
+ })
+ })
+
+ expect(mockUploadKnowledgeDocumentSession).not.toHaveBeenCalled()
+ expect(mockInvalidateQueries).not.toHaveBeenCalled()
+ expect(result().isUploading).toBe(false)
+ expect(result().uploadProgress).toEqual({
+ stage: 'idle',
+ filesCompleted: 0,
+ totalFiles: 0,
+ })
+ expect(result().uploadError).toMatchObject({
+ code: 'UPLOAD_TOTAL_SIZE_EXCEEDED',
+ message: 'Select files totaling 500 MiB or less.',
+ })
+ expect(onError).toHaveBeenCalledWith(
+ expect.objectContaining({ code: 'UPLOAD_TOTAL_SIZE_EXCEEDED' })
+ )
+
+ unmount()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts
index 3d096f53535..c9cd91416ef 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts
@@ -1,38 +1,20 @@
import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
-import { sleep } from '@sim/utils/helpers'
import { useQueryClient } from '@tanstack/react-query'
+import type { V2KnowledgeDocumentSummary } from '@/lib/api/contracts/v2/knowledge'
import {
- calculateUploadTimeoutMs,
- DirectUploadError,
- isTransientUploadError,
- LARGE_FILE_THRESHOLD,
- MULTIPART_MAX_RETRIES,
- MULTIPART_RETRY_BACKOFF,
- MULTIPART_RETRY_DELAY_MS,
- normalizePresignedData,
- type PresignedUploadInfo,
- runUploadStrategy,
- runWithConcurrency,
- type UploadProgressEvent,
- WHOLE_FILE_PARALLEL_UPLOADS,
-} from '@/lib/uploads/client/direct-upload'
-import { getFileContentType, isAbortError, isNetworkError } from '@/lib/uploads/utils/file-utils'
+ assertMultiFileUploadAdmission,
+ MultiFileUploadAdmissionError,
+} from '@/lib/uploads/client/admission'
+import { runWithConcurrency, WHOLE_FILE_PARALLEL_UPLOADS } from '@/lib/uploads/client/concurrency'
+import { uploadKnowledgeDocumentSession } from '@/lib/uploads/client/session-upload'
+import type { UploadProgressEvent } from '@/lib/uploads/client/types'
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
const logger = createLogger('KnowledgeUpload')
-const KB_BATCH_PRESIGNED_ENDPOINT = '/api/files/presigned/batch?type=knowledge-base'
-const KB_API_UPLOAD_ENDPOINT = '/api/files/upload'
-
-const BATCH_REQUEST_SIZE = 50
-
-export interface UploadedFile {
- filename: string
- fileUrl: string
- fileSize: number
- mimeType: string
+interface KnowledgeDocumentUploadFile extends File {
tag1?: string
tag2?: string
tag3?: string
@@ -86,162 +68,15 @@ class KnowledgeUploadError extends Error {
}
}
-class ProcessingError extends KnowledgeUploadError {
- constructor(message: string, details?: unknown) {
- super(message, 'PROCESSING_ERROR', details)
- }
-}
-
-interface BatchPresignedFile {
- fileName: string
- contentType: string
- fileSize: number
-}
-
-/**
- * Fetch presigned upload data for the small files in `files`. Returns a sparse
- * array aligned with the input: entries for files >= LARGE_FILE_THRESHOLD are
- * `undefined` because those uploads use multipart and never consume a presigned
- * single-PUT URL.
- */
-const fetchBatchPresignedData = async (
- files: File[],
- workspaceId: string
-): Promise<(PresignedUploadInfo | undefined)[]> => {
- const result: (PresignedUploadInfo | undefined)[] = new Array(files.length).fill(undefined)
- const smallFileIndices: number[] = []
- for (let i = 0; i < files.length; i++) {
- if (files[i].size <= LARGE_FILE_THRESHOLD) smallFileIndices.push(i)
- }
- if (smallFileIndices.length === 0) return result
-
- const batchEndpoint = `${KB_BATCH_PRESIGNED_ENDPOINT}&workspaceId=${encodeURIComponent(workspaceId)}`
-
- for (let start = 0; start < smallFileIndices.length; start += BATCH_REQUEST_SIZE) {
- const batchIndices = smallFileIndices.slice(start, start + BATCH_REQUEST_SIZE)
- const batchFiles = batchIndices.map((i) => files[i])
- const body: { files: BatchPresignedFile[] } = {
- files: batchFiles.map((file) => ({
- fileName: file.name,
- contentType: getFileContentType(file),
- fileSize: file.size,
- })),
- }
-
- const response = await fetch(batchEndpoint, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- })
-
- if (!response.ok) {
- throw new Error(`Batch presigned URL generation failed: ${response.statusText}`)
- }
-
- const { files: presignedItems } = (await response.json()) as { files: unknown[] }
- batchIndices.forEach((fileIdx, batchPos) => {
- result[fileIdx] = normalizePresignedData(presignedItems[batchPos], batchFiles[batchPos].name)
- })
- }
-
- return result
-}
-
-/**
- * Server-proxied fallback used when cloud storage isn't configured.
- */
-const uploadFileThroughAPI = async (
- file: File,
- workspaceId: string | undefined
-): Promise<{ filePath: string }> => {
- const formData = new FormData()
- formData.append('file', file)
- formData.append('context', 'knowledge-base')
- if (workspaceId) formData.append('workspaceId', workspaceId)
-
- const controller = new AbortController()
- const timeoutId = setTimeout(() => controller.abort(), calculateUploadTimeoutMs(file.size))
-
- try {
- const response = await fetch(KB_API_UPLOAD_ENDPOINT, {
- method: 'POST',
- body: formData,
- signal: controller.signal,
- })
-
- if (!response.ok) {
- let errorData: { message?: string; error?: string } | null = null
- try {
- errorData = (await response.json()) as { message?: string; error?: string }
- } catch {}
- throw new KnowledgeUploadError(
- `Failed to upload ${file.name}: ${errorData?.message || errorData?.error || response.statusText}`,
- 'API_UPLOAD_ERROR',
- errorData
- )
- }
-
- const result = (await response.json()) as {
- fileInfo?: { path?: string }
- path?: string
- }
- const filePath = result.fileInfo?.path ?? result.path
- if (!filePath) {
- throw new KnowledgeUploadError(
- `Invalid upload response for ${file.name}: missing file path`,
- 'API_UPLOAD_ERROR',
- result
- )
- }
-
- return { filePath }
- } finally {
- clearTimeout(timeoutId)
- }
-}
-
-const toAbsoluteUrl = (path: string): string =>
- path.startsWith('http') ? path : `${window.location.origin}${path}`
-
-/**
- * Build the {@link UploadedFile} payload from a `File`, carrying through any
- * `tagN` fields the caller attached to it. Pure — kept at module scope so it
- * isn't rebuilt on every render of the hook.
- */
-const buildUploadedFile = (file: File, fileUrl: string): UploadedFile => {
- const f = file as File & {
- tag1?: string
- tag2?: string
- tag3?: string
- tag4?: string
- tag5?: string
- tag6?: string
- tag7?: string
- }
- return {
- filename: file.name,
- fileUrl,
- fileSize: file.size,
- mimeType: getFileContentType(file),
- tag1: f.tag1,
- tag2: f.tag2,
- tag3: f.tag3,
- tag4: f.tag4,
- tag5: f.tag5,
- tag6: f.tag6,
- tag7: f.tag7,
- }
-}
-
export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
const queryClient = useQueryClient()
- const [isUploading, setIsUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState({
stage: 'idle',
filesCompleted: 0,
totalFiles: 0,
})
const [uploadError, setUploadError] = useState(null)
+ const isUploading = uploadProgress.stage !== 'idle'
const updateFileStatus = (fileIndex: number, patch: Partial) => {
setUploadProgress((prev) => ({
@@ -255,51 +90,40 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
const uploadOneFile = async (
file: File,
fileIndex: number,
- presigned: PresignedUploadInfo | undefined
- ): Promise => {
+ knowledgeBaseId: string,
+ processingOptions: ProcessingOptions
+ ): Promise => {
if (!options.workspaceId) {
throw new KnowledgeUploadError('workspaceId is required for upload', 'MISSING_WORKSPACE_ID')
}
-
const onProgress = (event: UploadProgressEvent) => {
updateFileStatus(fileIndex, { progress: event.percent, status: 'uploading' })
}
-
- let attempt = 0
- while (true) {
- try {
- const result = await runUploadStrategy({
- file,
- workspaceId: options.workspaceId,
- context: 'knowledge-base',
- presignedEndpoint: `/api/files/presigned?type=knowledge-base&workspaceId=${encodeURIComponent(options.workspaceId)}`,
- presignedOverride: presigned,
- onProgress,
- })
- return buildUploadedFile(file, toAbsoluteUrl(result.path))
- } catch (error) {
- if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') {
- const { filePath } = await uploadFileThroughAPI(file, options.workspaceId)
- return buildUploadedFile(file, toAbsoluteUrl(filePath))
- }
-
- const retryable = isNetworkError(error) || isTransientUploadError(error)
- if (isAbortError(error) || !retryable || attempt >= MULTIPART_MAX_RETRIES) {
- throw error
- }
-
- const delay = MULTIPART_RETRY_DELAY_MS * MULTIPART_RETRY_BACKOFF ** attempt
- attempt++
- logger.warn(
- `Upload retry ${attempt}/${MULTIPART_MAX_RETRIES} for ${file.name} in ${Math.round(delay / 1000)}s`
- )
- updateFileStatus(fileIndex, { progress: 0, status: 'uploading' })
- await sleep(delay)
- }
- }
+ const taggedFile = file as KnowledgeDocumentUploadFile
+ return uploadKnowledgeDocumentSession({
+ workspaceId: options.workspaceId,
+ knowledgeBaseId,
+ file,
+ onProgress,
+ tag1: taggedFile.tag1,
+ tag2: taggedFile.tag2,
+ tag3: taggedFile.tag3,
+ tag4: taggedFile.tag4,
+ tag5: taggedFile.tag5,
+ tag6: taggedFile.tag6,
+ tag7: taggedFile.tag7,
+ processingOptions: {
+ recipe: processingOptions.recipe ?? 'default',
+ lang: 'en',
+ },
+ })
}
- const uploadFilesInBatches = async (files: File[]): Promise => {
+ const uploadFilesInBatches = async (
+ files: File[],
+ knowledgeBaseId: string,
+ processingOptions: ProcessingOptions
+ ): Promise => {
if (!options.workspaceId) {
throw new KnowledgeUploadError('workspaceId is required for upload', 'MISSING_WORKSPACE_ID')
}
@@ -313,9 +137,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
setUploadProgress((prev) => ({ ...prev, fileStatuses }))
- logger.info(`Starting batch upload of ${files.length} files`)
-
- const presignedData = await fetchBatchPresignedData(files, options.workspaceId)
+ logger.info(`Starting signed session upload of ${files.length} files`)
const settled = await runWithConcurrency(
files,
@@ -323,7 +145,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
async (file, index) => {
updateFileStatus(index, { status: 'uploading' })
try {
- const uploaded = await uploadOneFile(file, index, presignedData[index])
+ const uploaded = await uploadOneFile(file, index, knowledgeBaseId, processingOptions)
setUploadProgress((prev) => ({
...prev,
filesCompleted: prev.filesCompleted + 1,
@@ -337,7 +159,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
}
)
- const succeeded: UploadedFile[] = []
+ const succeeded: V2KnowledgeDocumentSummary[] = []
const failed: Array<{ file: File; error: Error }> = []
settled.forEach((result, idx) => {
if (result?.status === 'fulfilled') {
@@ -354,7 +176,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
throw new KnowledgeUploadError(
`Failed to upload ${failed.length} file(s)`,
'PARTIAL_UPLOAD_FAILURE',
- { failedFiles: failed, uploadedFiles: succeeded }
+ { failedFiles: failed, uploadedDocuments: succeeded }
)
}
@@ -365,7 +187,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
files: File[],
knowledgeBaseId: string,
processingOptions: ProcessingOptions = {}
- ): Promise => {
+ ): Promise => {
if (files.length === 0) {
throw new KnowledgeUploadError('No files provided for upload', 'NO_FILES')
}
@@ -374,77 +196,30 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
}
try {
- setIsUploading(true)
+ assertMultiFileUploadAdmission(files)
setUploadError(null)
setUploadProgress({ stage: 'uploading', filesCompleted: 0, totalFiles: files.length })
- const uploadedFiles = await uploadFilesInBatches(files)
+ const uploadedDocuments = await uploadFilesInBatches(
+ files,
+ knowledgeBaseId,
+ processingOptions
+ )
setUploadProgress((prev) => ({ ...prev, stage: 'processing' }))
-
- // boundary-raw-fetch: bulk document-processing kickoff with dynamic recipe payload; response is consumed alongside the upload progress lifecycle and not modeled by a single contract
- const processResponse = await fetch(`/api/knowledge/${knowledgeBaseId}/documents`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- documents: uploadedFiles.map((f) => ({ ...f })),
- processingOptions: {
- recipe: processingOptions.recipe ?? 'default',
- lang: 'en',
- },
- bulk: true,
- }),
- })
-
- if (!processResponse.ok) {
- let errorData: { error?: string; message?: string } | null = null
- try {
- errorData = (await processResponse.json()) as { error?: string; message?: string }
- } catch {}
- logger.error('Document processing failed:', {
- status: processResponse.status,
- error: errorData,
- })
- throw new ProcessingError(
- `Failed to start document processing: ${errorData?.error || errorData?.message || 'Unknown error'}`,
- errorData
- )
- }
-
- const processResult = (await processResponse.json()) as {
- success?: boolean
- error?: string
- data?: { documentsCreated?: unknown }
- }
-
- if (!processResult.success) {
- throw new ProcessingError(
- `Document processing failed: ${processResult.error || 'Unknown error'}`,
- processResult
- )
- }
-
- if (!processResult.data?.documentsCreated) {
- throw new ProcessingError(
- 'Invalid processing response: missing document data',
- processResult
- )
- }
-
- setUploadProgress((prev) => ({ ...prev, stage: 'completing' }))
- logger.info(`Successfully started processing ${uploadedFiles.length} documents`)
+ logger.info(`Successfully started processing ${uploadedDocuments.length} documents`)
await queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId) })
- return uploadedFiles
+ return uploadedDocuments
} catch (err) {
logger.error('Error uploading documents:', err)
const error: UploadError =
err instanceof KnowledgeUploadError
? { message: err.message, code: err.code, details: err.details, timestamp: Date.now() }
- : err instanceof DirectUploadError
- ? { message: err.message, code: err.code, details: err.details, timestamp: Date.now() }
+ : err instanceof MultiFileUploadAdmissionError
+ ? { message: err.message, code: err.code, timestamp: Date.now() }
: err instanceof Error
? { message: err.message, timestamp: Date.now() }
: { message: 'Unknown error occurred during upload', timestamp: Date.now() }
@@ -453,7 +228,6 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
options.onError?.(error)
throw err
} finally {
- setIsUploading(false)
setUploadProgress({ stage: 'idle', filesCompleted: 0, totalFiles: 0 })
}
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx
index 18402ab42ae..c4e815a9597 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx
@@ -56,7 +56,7 @@ function UsageLogRow({ log }: UsageLogRowProps) {
{rowLabel(log)}
- {formatApportionedCreditCost(log.creditCost, log.dollarCost)}
+ {formatApportionedCreditCost(log.creditCost, log.hasCost)}
)
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts b/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts
index 09fdbd75eff..879b875d637 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts
+++ b/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts
@@ -1,8 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
-import { uploadViaApiFallback } from '@/lib/uploads/client/api-fallback'
-import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload'
+import { uploadInternalFileSession } from '@/lib/uploads/client/session-upload'
const logger = createLogger('ProfilePictureUpload')
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
@@ -66,28 +65,25 @@ export function useProfilePictureUpload({
const uploadFileToServer = useCallback(
async (file: File): Promise => {
- const presignedEndpoint =
- context === 'workspace-logos' && workspaceId
- ? `/api/files/presigned?type=workspace-logos&workspaceId=${encodeURIComponent(workspaceId)}`
- : `/api/files/presigned?type=${context}`
-
- try {
- const result = await runUploadStrategy({
+ if (context === 'workspace-logos') {
+ if (!workspaceId) {
+ throw new Error('workspaceId is required for workspace logo upload')
+ }
+ const result = await uploadInternalFileSession({
+ purpose: 'workspace_logo',
+ workspaceId,
file,
- workspaceId: workspaceId ?? '',
- context,
- presignedEndpoint,
})
logger.info(`${context} uploaded successfully: ${result.path}`)
return result.path
- } catch (error) {
- if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') {
- const { path } = await uploadViaApiFallback(file, context, workspaceId)
- logger.info(`${context} uploaded successfully via API fallback: ${path}`)
- return path
- }
- throw error
}
+
+ const result = await uploadInternalFileSession({
+ purpose: 'profile_picture',
+ file,
+ })
+ logger.info(`${context} uploaded successfully: ${result.path}`)
+ return result.path
},
[context, workspaceId]
)
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts
index 6d7fa21927e..341c581af1b 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts
@@ -253,7 +253,7 @@ export function useTableEventStream({
// Keep the tray's export list fresh between its polls.
void queryClient.invalidateQueries({ queryKey: tableKeys.exportJobs(workspaceId) })
if (status === 'ready' && jobId && consumeInitiatedExport(jobId)) {
- void downloadExportResult(workspaceId, tableId, jobId)
+ void downloadExportResult(workspaceId, jobId)
.then(() => toast.success('Export ready — downloading'))
.catch((err) => {
logger.error('Export download failed', { tableId, jobId, err })
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
index 13191a7347a..e193f6e3da8 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
@@ -21,7 +21,6 @@ import type {
WorkflowGroup,
} from '@/lib/table'
import { getColumnId } from '@/lib/table/column-keys'
-import { TABLE_LIMITS } from '@/lib/table/constants'
import {
type BreadcrumbItem,
type ColumnOption,
@@ -35,13 +34,13 @@ import { ImportCsvDialog } from '@/app/workspace/[workspaceId]/tables/components
import { ImportProgressMenu } from '@/app/workspace/[workspaceId]/tables/components/import-progress-menu'
import { useLogByExecutionId } from '@/hooks/queries/logs'
import {
- downloadTableExport,
+ downloadExportResult,
useCancelTableRuns,
useCreateTableView,
useDeleteTable,
useDeleteTableRowsAsync,
useDeleteTableView,
- useExportTableAsync,
+ useExportTable,
useRenameTable,
useRunColumn,
useTableViews,
@@ -1026,16 +1025,11 @@ export function Table({
const handleExportCsv = useCallback(async () => {
if (!tableData) return
try {
- // Big tables export as a background job (the file downloads when the job completes via the
- // SSE stream); small ones keep the instant synchronous stream. While a delete job runs,
- // rowCount is a doomed-estimate-adjusted number — not ground truth — so always take the
- // async path (safe at any size; exports bypass the one-job-per-table gate).
- const deleteRunning = tableData.jobType === 'delete' && tableData.jobStatus === 'running'
- if (deleteRunning || tableData.rowCount > TABLE_LIMITS.EXPORT_ASYNC_THRESHOLD_ROWS) {
- await exportTableAsync.mutateAsync({ format: 'csv' })
- toast.success('Export started — the download will begin when it finishes')
+ const exported = await exportTableAsync.mutateAsync({ format: 'csv' })
+ if (exported.status === 'completed') {
+ await downloadExportResult(workspaceId, exported.id)
} else {
- await downloadTableExport(tableData.id, tableData.name)
+ toast.success('Export started — the download will begin when it finishes')
}
captureEvent(posthogRef.current, 'table_exported', {
table_id: tableData.id,
@@ -1256,7 +1250,7 @@ export function Table({
const deleteTableMutation = useDeleteTable(workspaceId)
const deleteRowsAsyncMutation = useDeleteTableRowsAsync({ workspaceId, tableId })
- const exportTableAsync = useExportTableAsync({ workspaceId, tableId })
+ const exportTableAsync = useExportTable({ workspaceId, tableId })
const handleDeleteTable = async () => {
try {
await deleteTableMutation.mutateAsync(tableId)
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx
index e306aeac52d..de5983f975b 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx
@@ -24,7 +24,6 @@ import {
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
-import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES } from '@/lib/table/constants'
import {
buildAutoMapping,
CSV_DELIMITER_SNIFF_BYTES,
@@ -33,12 +32,7 @@ import {
parseCsvBuffer,
} from '@/lib/table/import'
import type { TableDefinition } from '@/lib/table/types'
-import {
- type CsvImportMode,
- cancelTableJob,
- useImportCsvIntoTable,
- useImportCsvIntoTableAsync,
-} from '@/hooks/queries/tables'
+import { type CsvImportMode, useImportCsvIntoTable } from '@/hooks/queries/tables'
import { useImportTrayStore } from '@/stores/table/import-tray/store'
const logger = createLogger('ImportCsvDialog')
@@ -152,7 +146,6 @@ export function ImportCsvDialog({
const [createHeaders, setCreateHeaders] = useState>(new Set())
const [mode, setMode] = useState('append')
const importMutation = useImportCsvIntoTable()
- const importAsyncMutation = useImportCsvIntoTableAsync()
function resetState() {
setParsed(null)
@@ -306,7 +299,6 @@ export function ImportCsvDialog({
const canSubmit =
parsed !== null &&
!importMutation.isPending &&
- !importAsyncMutation.isPending &&
missingRequired.length === 0 &&
duplicateTargets.length === 0 &&
mappedCount + createCount > 0
@@ -320,76 +312,44 @@ export function ImportCsvDialog({
const createColumns =
canCreateColumns && createHeaders.size > 0 ? [...createHeaders] : undefined
- // Large files can't be POSTed through the server (request-body cap) — upload them
- // straight to storage and import in the background instead. Seed the header tray and
- // close the dialog immediately so the indicator is visible during the upload, then run
- // the upload + kickoff in the background (don't block the dialog on it).
- if (parsed.file.size >= CSV_ASYNC_IMPORT_THRESHOLD_BYTES) {
- useImportTrayStore.getState().startUpload({
- uploadId: table.id,
- workspaceId,
- title: parsed.file.name,
- })
- onOpenChange(false)
- toast.success(`Importing "${parsed.file.name}" into "${table.name}" in the background`)
- importAsyncMutation.mutate(
- {
- workspaceId,
- tableId: table.id,
- file: parsed.file,
- mode: effectiveMode,
- mapping,
- createColumns,
- onProgress: (percent) => {
- useImportTrayStore.getState().setUploadPercent(table.id, percent)
- },
- },
- {
- onSuccess: (data) => {
- useImportTrayStore.getState().endUpload(table.id)
- // The server row drives the tray once the list refetches. If canceled mid-upload, flag
- // the id so it's not shown and cancel the worker server-side.
- if (useImportTrayStore.getState().consumeCanceled(table.id) && data?.importId) {
- useImportTrayStore.getState().cancel(table.id)
- void cancelTableJob(workspaceId, table.id, data.importId).catch(() => {})
- }
- },
- onError: () => {
- // The hook's onError surfaces the toast; just clear the tray indicator here.
- useImportTrayStore.getState().endUpload(table.id)
- },
- }
- )
- return
- }
-
- try {
- const result = await importMutation.mutateAsync({
+ let importId: string | null = null
+ onOpenChange(false)
+ toast.success(`Importing "${parsed.file.name}" into "${table.name}" in the background`)
+ importMutation.mutate(
+ {
workspaceId,
tableId: table.id,
file: parsed.file,
mode: effectiveMode,
mapping,
createColumns,
- })
- const data = result.data
- if (effectiveMode === 'append') {
- toast.success(`Imported ${data?.insertedCount ?? 0} rows into "${table.name}"`)
- } else {
- toast.success(
- `Replaced rows in "${table.name}": deleted ${data?.deletedCount ?? 0}, inserted ${data?.insertedCount ?? 0}`
- )
+ onCreated: (createdImportId) => {
+ importId = createdImportId
+ useImportTrayStore.getState().startUpload({
+ uploadId: createdImportId,
+ tableId: table.id,
+ workspaceId,
+ title: parsed.file.name,
+ })
+ },
+ onProgress: (percent) => {
+ if (importId) useImportTrayStore.getState().setUploadPercent(importId, percent)
+ },
+ },
+ {
+ onSuccess: () => {
+ if (importId) {
+ useImportTrayStore.getState().endUpload(importId)
+ useImportTrayStore.getState().consumeCanceled(importId)
+ }
+ onImported?.({})
+ },
+ onError: (error) => {
+ if (importId) useImportTrayStore.getState().endUpload(importId)
+ setSubmitError(summarizeImportError(error.message))
+ },
}
- onImported?.({
- insertedCount: data?.insertedCount,
- deletedCount: data?.deletedCount,
- })
- onOpenChange(false)
- } catch (err) {
- const message = getErrorMessage(err, 'Failed to import CSV')
- setSubmitError(summarizeImportError(message))
- logger.error('CSV import into existing table failed', err)
- }
+ )
}
const hasWarning = missingRequired.length > 0 || duplicateTargets.length > 0
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx
index 46deda65b1c..9cb0c793d6a 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx
@@ -10,7 +10,7 @@ import {
} from '@sim/emcn'
import { CircleAlert, CircleCheck, Loader } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
-import { cancelTableJob, downloadExportResult } from '@/hooks/queries/tables'
+import { cancelTableImport, downloadExportResult } from '@/hooks/queries/tables'
import { useImportTrayStore } from '@/stores/table/import-tray/store'
import { getImportStage } from './import-stage'
import { type ImportRow, useWorkspaceImports } from './use-workspace-imports'
@@ -49,13 +49,13 @@ export function ImportProgressMenu({ workspaceId, tableId }: ImportProgressMenuP
// Worker already running — cancel it server-side now. (An upload still mid-flight is canceled by
// the kickoff handler once its jobId is known; see the `consumeCanceled` branches.)
if (row.jobId) {
- void cancelTableJob(row.workspaceId, row.tableId, row.jobId).catch(() => {})
+ void cancelTableImport(row.workspaceId, row.jobId).catch(() => {})
}
}
const download = (row: ImportRow) => {
if (!row.jobId) return
- void downloadExportResult(row.workspaceId, row.tableId, row.jobId).catch((err) => {
+ void downloadExportResult(row.workspaceId, row.jobId).catch((err) => {
logger.error('Export download failed', { jobId: row.jobId, err })
toast.error('Download failed — the export may have expired')
})
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts
index d72ed8b6d20..934757a198e 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts
+++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts
@@ -132,17 +132,18 @@ export function useWorkspaceImports(
for (const upload of uploads) {
if (upload.workspaceId !== workspaceId) continue
- if (scopeTableId && upload.uploadId !== scopeTableId) continue
+ if (scopeTableId && upload.tableId !== scopeTableId) continue
if (canceledIds[upload.uploadId] || seen.has(upload.uploadId)) continue
rows.push({
id: upload.uploadId,
- tableId: upload.uploadId,
+ tableId: upload.tableId ?? upload.uploadId,
workspaceId: upload.workspaceId,
title: upload.title,
phase: 'importing',
jobType: 'import',
rowsProcessed: 0,
percent: upload.percent,
+ jobId: upload.uploadId,
})
}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
index b7b7dea07dd..e6292d6accd 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
@@ -6,11 +6,10 @@ import { ChipCombobox, ChipConfirmModal, Plus, toast, Upload } from '@sim/emcn'
import { Columns3, FolderPlus, Rows3, Table as TableIcon } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
-import { generateId } from '@sim/utils/id'
import { useParams, useRouter } from 'next/navigation'
import { useQueryStates } from 'nuqs'
import type { TableDefinition } from '@/lib/table'
-import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES, generateUniqueTableName } from '@/lib/table/constants'
+import { generateUniqueTableName } from '@/lib/table/constants'
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
import type {
DropdownOption,
@@ -62,16 +61,15 @@ import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sideb
import { useCreateFolder, useDeleteFolderMutation, useUpdateFolder } from '@/hooks/queries/folders'
import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items'
import {
- cancelTableJob,
- downloadTableExport,
+ exportTable,
useCreateTable,
useDeleteTable,
- useImportCsvAsync,
+ useImportCsv,
useMoveTable,
useRenameTable,
useTablesList,
- useUploadCsvToTable,
} from '@/hooks/queries/tables'
+import { getCanonicalFolderPath } from '@/hooks/queries/utils/folder-tree'
import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace'
import { useDebounce } from '@/hooks/use-debounce'
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
@@ -154,8 +152,7 @@ export function Tables() {
const renameTable = useRenameTable(workspaceId)
const createTable = useCreateTable(workspaceId)
const moveTable = useMoveTable(workspaceId)
- const uploadCsv = useUploadCsvToTable()
- const importCsvAsync = useImportCsvAsync()
+ const importCsv = useImportCsv()
const createFolder = useCreateFolder()
const updateFolder = useUpdateFolder()
const deleteFolder = useDeleteFolderMutation()
@@ -866,112 +863,64 @@ export function Tables() {
}
}
- const handleCsvChange = useCallback(
- async (e: React.ChangeEvent) => {
- const list = e.target.files
- if (!list || list.length === 0 || !workspaceId) return
+ const handleCsvChange = async (e: React.ChangeEvent) => {
+ const list = e.target.files
+ if (!list || list.length === 0 || !workspaceId) return
- const csvFiles = Array.from(list).filter((f) => {
- const ext = f.name.split('.').pop()?.toLowerCase()
- return ext === 'csv' || ext === 'tsv'
- })
-
- if (csvFiles.length === 0) {
- toast.error('No CSV or TSV files selected')
- if (csvInputRef.current) csvInputRef.current.value = ''
- return
- }
-
- // Large files can't be POSTed through the server (request-body cap) — upload them
- // straight to storage and import in the background. These are tracked by the import
- // tray, never the header upload button, so don't touch uploading/uploadProgress here.
- const asyncFiles = csvFiles.filter((f) => f.size >= CSV_ASYNC_IMPORT_THRESHOLD_BYTES)
- const syncFiles = csvFiles.filter((f) => f.size < CSV_ASYNC_IMPORT_THRESHOLD_BYTES)
-
- try {
- for (const file of asyncFiles) {
- // Show the indicator immediately under a temporary id (the real table id doesn't
- // exist until kickoff returns), then let the tray track it. Don't redirect — the
- // table is still empty/importing, so stay on the list.
- const pendingId = `pending_${generateId()}`
- useImportTrayStore
- .getState()
- .startUpload({ uploadId: pendingId, workspaceId, title: file.name })
- toast.success(`Importing "${file.name}" in the background`)
- try {
- const result = await importCsvAsync.mutateAsync({
- workspaceId,
- folderId: currentFolderId,
- file,
- onProgress: (percent) => {
- useImportTrayStore.getState().setUploadPercent(pendingId, percent)
- },
- })
- useImportTrayStore.getState().endUpload(pendingId)
- // The server row drives the tray once the list refetches (mutation invalidates it).
- // If canceled mid-upload, flag the real id so it's not shown and cancel server-side.
- if (
- result?.tableId &&
- result.importId &&
- useImportTrayStore.getState().consumeCanceled(pendingId)
- ) {
- useImportTrayStore.getState().cancel(result.tableId)
- void cancelTableJob(workspaceId, result.tableId, result.importId).catch(() => {})
- }
- } catch {
- // The hook's onError surfaces the toast; just clear the tray indicator here.
- useImportTrayStore.getState().endUpload(pendingId)
- }
- }
-
- if (syncFiles.length === 0) return
+ const csvFiles = Array.from(list).filter((f) => {
+ const ext = f.name.split('.').pop()?.toLowerCase()
+ return ext === 'csv' || ext === 'tsv'
+ })
- setUploadProgress({ completed: 0, total: syncFiles.length })
- const failed: string[] = []
+ if (csvFiles.length === 0) {
+ toast.error('No CSV or TSV files selected')
+ if (csvInputRef.current) csvInputRef.current.value = ''
+ return
+ }
- for (let i = 0; i < syncFiles.length; i++) {
- const file = syncFiles[i]
- try {
- const result = await uploadCsv.mutateAsync({
- workspaceId,
- folderId: currentFolderId,
- file,
- })
-
- if (syncFiles.length === 1 && asyncFiles.length === 0) {
- const tableId = result?.data?.table?.id
- if (tableId) {
- router.push(`/workspace/${workspaceId}/tables/${tableId}`)
- }
- }
- } catch (err) {
- failed.push(file.name)
- logger.error('Error uploading CSV:', err)
- } finally {
- setUploadProgress({ completed: i + 1, total: syncFiles.length })
+ try {
+ setUploadProgress({ completed: 0, total: csvFiles.length })
+ for (let index = 0; index < csvFiles.length; index++) {
+ const file = csvFiles[index]
+ let importId: string | null = null
+ toast.success(`Importing "${file.name}" in the background`)
+ try {
+ await importCsv.mutateAsync({
+ workspaceId,
+ folderPath: getCanonicalFolderPath(currentFolderId, folderById),
+ file,
+ onCreated: (createdImportId) => {
+ importId = createdImportId
+ useImportTrayStore.getState().startUpload({
+ uploadId: createdImportId,
+ workspaceId,
+ title: file.name,
+ })
+ },
+ onProgress: (percent) => {
+ if (importId) useImportTrayStore.getState().setUploadPercent(importId, percent)
+ },
+ })
+ if (importId) {
+ useImportTrayStore.getState().endUpload(importId)
+ useImportTrayStore.getState().consumeCanceled(importId)
}
- }
-
- if (failed.length > 0) {
- toast.error(
- failed.length === 1
- ? `Failed to import ${failed[0]}`
- : `Failed to import ${failed.length} file${failed.length > 1 ? 's' : ''}: ${failed.join(', ')}`
- )
- }
- } catch (err) {
- logger.error('Error uploading CSV:', err)
- toast.error('Failed to import CSV')
- } finally {
- setUploadProgress({ completed: 0, total: 0 })
- if (csvInputRef.current) {
- csvInputRef.current.value = ''
+ } catch {
+ if (importId) useImportTrayStore.getState().endUpload(importId)
+ } finally {
+ setUploadProgress({ completed: index + 1, total: csvFiles.length })
}
}
- },
- // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5
- [workspaceId, currentFolderId, router]
- )
+ } catch (err) {
+ logger.error('Error uploading CSV:', err)
+ toast.error('Failed to import CSV')
+ } finally {
+ setUploadProgress({ completed: 0, total: 0 })
+ if (csvInputRef.current) {
+ csvInputRef.current.value = ''
+ }
+ }
+ }
const handleListUploadCsv = useCallback(() => {
csvInputRef.current?.click()
@@ -1129,7 +1078,8 @@ export function Tables() {
onExportCsv={async () => {
if (!activeTable) return
try {
- await downloadTableExport(activeTable.id, activeTable.name)
+ const status = await exportTable(workspaceId, activeTable.id)
+ if (status === 'processing') toast.success('Export started')
} catch (err) {
logger.error('Failed to export table:', err)
toast.error('Failed to export table')
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.test.tsx
new file mode 100644
index 00000000000..9d1a3d26d63
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.test.tsx
@@ -0,0 +1,118 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockToastError, mockUploadInternalFileSession } = vi.hoisted(() => ({
+ mockToastError: vi.fn(),
+ mockUploadInternalFileSession: vi.fn(),
+}))
+
+vi.mock('@sim/emcn', () => ({ toast: { error: mockToastError } }))
+
+vi.mock('@/lib/uploads/client/session-upload', () => ({
+ uploadInternalFileSession: mockUploadInternalFileSession,
+}))
+
+import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types'
+import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
+
+interface HookHarness {
+ result: () => ReturnType
+ unmount: () => void
+}
+
+function renderFileAttachmentsHook(): HookHarness {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ const root: Root = createRoot(document.createElement('div'))
+ let latest: ReturnType
+
+ function Probe() {
+ latest = useFileAttachments({ userId: 'user-1', workspaceId: 'workspace-1' })
+ return null
+ }
+
+ act(() => root.render())
+ return {
+ result: () => latest,
+ unmount: () => act(() => root.unmount()),
+ }
+}
+
+function sizedFile(name: string, size: number): File {
+ const file = new File([], name, { type: 'image/png' })
+ Object.defineProperty(file, 'size', { value: size })
+ return file
+}
+
+function asFileList(files: File[]): FileList {
+ return Object.assign(files, { item: (index: number) => files[index] ?? null })
+}
+
+describe('useFileAttachments admission', () => {
+ const originalCreateObjectUrl = Object.getOwnPropertyDescriptor(URL, 'createObjectURL')
+ const createObjectUrl = vi.fn()
+
+ beforeEach(() => {
+ Object.defineProperty(URL, 'createObjectURL', {
+ configurable: true,
+ value: createObjectUrl,
+ })
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ if (originalCreateObjectUrl) {
+ Object.defineProperty(URL, 'createObjectURL', originalCreateObjectUrl)
+ } else {
+ Reflect.deleteProperty(URL, 'createObjectURL')
+ }
+ })
+
+ it('rejects aggregate bytes before previews, placeholders, or sessions are allocated', async () => {
+ const { result, unmount } = renderFileAttachmentsHook()
+ const files = asFileList([
+ ...Array.from({ length: 5 }, (_, index) =>
+ sizedFile(`large-image-${index}.png`, MAX_WORKSPACE_FILE_SIZE)
+ ),
+ sizedFile('extra-image.png', 1),
+ ])
+
+ await act(async () => {
+ await result().processFiles(files)
+ })
+
+ expect(mockToastError).toHaveBeenCalledWith("Couldn't add files", {
+ description: 'Select files totaling 25 GiB or less.',
+ })
+ expect(createObjectUrl).not.toHaveBeenCalled()
+ expect(mockUploadInternalFileSession).not.toHaveBeenCalled()
+ expect(result().attachedFiles).toEqual([])
+
+ unmount()
+ })
+
+ it('starts a mothership session for a file above the old FormData limit', async () => {
+ mockUploadInternalFileSession.mockResolvedValue({
+ path: '/api/files/serve/s3/mothership%2Flarge-image.png?context=mothership',
+ key: 'mothership/large-image.png',
+ })
+ const { result, unmount } = renderFileAttachmentsHook()
+ const file = sizedFile('large-image.png', 101 * 1024 * 1024)
+
+ await act(async () => {
+ await result().processFiles(asFileList([file]))
+ })
+
+ expect(mockUploadInternalFileSession).toHaveBeenCalledWith(
+ expect.objectContaining({ purpose: 'mothership_attachment', file })
+ )
+ expect(result().attachedFiles).toEqual([
+ expect.objectContaining({ name: file.name, uploading: false }),
+ ])
+
+ unmount()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts
index 4c40839d27b..ef68d2bb113 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts
@@ -5,8 +5,10 @@ import { toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
-import { uploadViaApiFallback } from '@/lib/uploads/client/api-fallback'
-import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload'
+import { assertMultiFileUploadAdmission } from '@/lib/uploads/client/admission'
+import { runWithConcurrency, WHOLE_FILE_PARALLEL_UPLOADS } from '@/lib/uploads/client/concurrency'
+import { uploadInternalFileSession } from '@/lib/uploads/client/session-upload'
+import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types'
import { resolveFileType } from '@/lib/uploads/utils/file-utils'
const logger = createLogger('useFileAttachments')
@@ -64,16 +66,26 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
const { userId, workspaceId, disabled, isLoading } = props
const [attachedFiles, setAttachedFiles] = useState([])
- const [isDragging, setIsDragging] = useState(false)
const [dragCounter, setDragCounter] = useState(0)
+ const isDragging = dragCounter > 0
const fileInputRef = useRef(null)
+ const attachedFilesRef = useRef([])
+ const uploadControllersRef = useRef(new Map())
+
+ const updateAttachedFiles = useCallback((update: (files: AttachedFile[]) => AttachedFile[]) => {
+ const next = update(attachedFilesRef.current)
+ attachedFilesRef.current = next
+ setAttachedFiles(next)
+ }, [])
/**
* Cleanup preview URLs on unmount
*/
useEffect(() => {
return () => {
- attachedFiles.forEach((f) => {
+ for (const controller of uploadControllersRef.current.values()) controller.abort()
+ uploadControllersRef.current.clear()
+ attachedFilesRef.current.forEach((f) => {
if (f.previewUrl) {
URL.revokeObjectURL(f.previewUrl)
}
@@ -122,8 +134,18 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
return
}
+ if (fileList.length === 0) return
+ try {
+ assertMultiFileUploadAdmission(fileList, {
+ existingFiles: attachedFilesRef.current,
+ maxFileBytes: MAX_WORKSPACE_FILE_SIZE,
+ })
+ } catch (error) {
+ toast.error("Couldn't add files", { description: toError(error).message })
+ return
+ }
+
const files = Array.from(fileList)
- if (files.length === 0) return
const placeholders: AttachedFile[] = files.map((file) => ({
id: generateId(),
@@ -137,56 +159,48 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
? URL.createObjectURL(file)
: undefined,
}))
+ const controllers = placeholders.map(() => new AbortController())
+ placeholders.forEach((placeholder, index) => {
+ uploadControllersRef.current.set(placeholder.id, controllers[index])
+ })
- setAttachedFiles((prev) => [...prev, ...placeholders])
-
- const presignedEndpoint = `/api/files/presigned?type=mothership&workspaceId=${encodeURIComponent(workspaceId)}`
-
- await Promise.all(
- files.map(async (file, i) => {
- const placeholder = placeholders[i]
- try {
- let result: { path: string; key: string }
- try {
- result = await runUploadStrategy({
- file,
- workspaceId,
- context: 'mothership',
- presignedEndpoint,
- })
- } catch (error) {
- if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') {
- const fallback = await uploadViaApiFallback(file, 'mothership', workspaceId)
- if (!fallback.key) {
- throw new Error('Invalid upload response: missing key')
- }
- result = { path: fallback.path, key: fallback.key }
- } else {
- throw error
- }
- }
-
- logger.info(`File uploaded successfully: ${result.path}`)
-
- setAttachedFiles((prev) =>
- prev.map((f) =>
- f.id === placeholder.id
- ? { ...f, path: result.path, key: result.key, uploading: false }
- : f
- )
+ updateAttachedFiles((current) => [...current, ...placeholders])
+
+ await runWithConcurrency(files, WHOLE_FILE_PARALLEL_UPLOADS, async (file, i) => {
+ const placeholder = placeholders[i]
+ const controller = controllers[i]
+ try {
+ const result = await uploadInternalFileSession({
+ purpose: 'mothership_attachment',
+ file,
+ workspaceId,
+ signal: controller.signal,
+ })
+
+ logger.info(`File uploaded successfully: ${result.path}`)
+
+ updateAttachedFiles((current) =>
+ current.map((f) =>
+ f.id === placeholder.id
+ ? { ...f, path: result.path, key: result.key, uploading: false }
+ : f
)
- } catch (error) {
+ )
+ } catch (error) {
+ if (!controller.signal.aborted) {
logger.error(`File upload failed: ${error}`)
toast.error(`Couldn't upload "${file.name}"`, {
description: toError(error).message,
})
- if (placeholder.previewUrl) URL.revokeObjectURL(placeholder.previewUrl)
- setAttachedFiles((prev) => prev.filter((f) => f.id !== placeholder.id))
}
- })
- )
+ if (placeholder.previewUrl) URL.revokeObjectURL(placeholder.previewUrl)
+ updateAttachedFiles((current) => current.filter((file) => file.id !== placeholder.id))
+ } finally {
+ uploadControllersRef.current.delete(placeholder.id)
+ }
+ })
},
- [userId, workspaceId]
+ [userId, workspaceId, updateAttachedFiles]
)
/**
@@ -222,13 +236,15 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
*/
const removeFile = useCallback(
(fileId: string) => {
- const file = attachedFiles.find((f) => f.id === fileId)
+ uploadControllersRef.current.get(fileId)?.abort()
+ uploadControllersRef.current.delete(fileId)
+ const file = attachedFilesRef.current.find((f) => f.id === fileId)
if (file?.previewUrl) {
URL.revokeObjectURL(file.previewUrl)
}
- setAttachedFiles((prev) => prev.filter((f) => f.id !== fileId))
+ updateAttachedFiles((current) => current.filter((file) => file.id !== fileId))
},
- [attachedFiles]
+ [updateAttachedFiles]
)
/**
@@ -249,13 +265,7 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
- setDragCounter((prev) => {
- const newCount = prev + 1
- if (newCount === 1) {
- setIsDragging(true)
- }
- return newCount
- })
+ setDragCounter((prev) => prev + 1)
}, [])
/**
@@ -264,13 +274,7 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
- setDragCounter((prev) => {
- const newCount = prev - 1
- if (newCount === 0) {
- setIsDragging(false)
- }
- return newCount
- })
+ setDragCounter((prev) => Math.max(0, prev - 1))
}, [])
/**
@@ -289,7 +293,6 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
async (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
- setIsDragging(false)
setDragCounter(0)
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
@@ -303,26 +306,33 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
* Clears all attached files and cleanup preview URLs
*/
const clearAttachedFiles = useCallback(() => {
- attachedFiles.forEach((f) => {
+ for (const controller of uploadControllersRef.current.values()) controller.abort()
+ uploadControllersRef.current.clear()
+ attachedFilesRef.current.forEach((f) => {
if (f.previewUrl) {
URL.revokeObjectURL(f.previewUrl)
}
})
- setAttachedFiles([])
- }, [attachedFiles])
+ updateAttachedFiles(() => [])
+ }, [updateAttachedFiles])
/**
* Replaces the current attached files with a given set.
* Cleans up preview URLs from the prior set before replacing.
*/
- const restoreAttachedFiles = useCallback((files: AttachedFile[]) => {
- setAttachedFiles((prev) => {
- prev.forEach((f) => {
- if (f.previewUrl) URL.revokeObjectURL(f.previewUrl)
+ const restoreAttachedFiles = useCallback(
+ (files: AttachedFile[]) => {
+ for (const controller of uploadControllersRef.current.values()) controller.abort()
+ uploadControllersRef.current.clear()
+ updateAttachedFiles((current) => {
+ current.forEach((f) => {
+ if (f.previewUrl) URL.revokeObjectURL(f.previewUrl)
+ })
+ return files
})
- return files
- })
- }, [])
+ },
+ [updateAttachedFiles]
+ )
return {
// State
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx
index c468acb2a3d..a5950e82f60 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx
@@ -421,7 +421,7 @@ console.log(limits);`
case 'status':
return 'Check Status'
case 'rate-limits':
- return 'Rate Limits'
+ return 'Usage Limits'
default:
return 'Execute Job'
}
@@ -564,7 +564,7 @@ console.log(limits);`
options={[
{ label: 'Execute Job', value: 'execute' },
{ label: 'Check Status', value: 'status' },
- { label: 'Rate Limits', value: 'rate-limits' },
+ { label: 'Usage Limits', value: 'rate-limits' },
]}
value={asyncExampleType}
onChange={(value) => setAsyncExampleType(value as AsyncExampleType)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx
index 3ed0c144334..3ba4638306d 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx
@@ -7,28 +7,17 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
- DirectUploadErrorMock,
executionStoreState,
mockExecute,
mockExecuteFromBlock,
mockFetch,
mockResolveStartCandidates,
- mockRunUploadStrategy,
mockSelectBestTrigger,
+ mockUploadInternalFileSession,
terminalStoreState,
workflowBlocks,
workflowStoreState,
} = vi.hoisted(() => {
- class DirectUploadErrorMock extends Error {
- constructor(
- message: string,
- public code: string
- ) {
- super(message)
- this.name = 'DirectUploadError'
- }
- }
-
const workflowBlocks = {
start: {
id: 'start',
@@ -88,14 +77,13 @@ const {
}
return {
- DirectUploadErrorMock,
executionStoreState,
mockExecute: vi.fn(),
mockExecuteFromBlock: vi.fn(),
mockFetch: vi.fn(),
mockResolveStartCandidates: vi.fn(),
- mockRunUploadStrategy: vi.fn(),
mockSelectBestTrigger: vi.fn(),
+ mockUploadInternalFileSession: vi.fn(),
terminalStoreState,
workflowBlocks,
workflowStoreState,
@@ -127,9 +115,8 @@ vi.mock('@/lib/tokenization', () => ({
processStreamingBlockLogs: () => 0,
}))
-vi.mock('@/lib/uploads/client/direct-upload', () => ({
- DirectUploadError: DirectUploadErrorMock,
- runUploadStrategy: mockRunUploadStrategy,
+vi.mock('@/lib/uploads/client/session-upload', () => ({
+ uploadInternalFileSession: mockUploadInternalFileSession,
}))
vi.mock('@/lib/workflows/input-format', () => ({
@@ -354,8 +341,8 @@ describe('useWorkflowExecution attachment uploads', () => {
mockResolveStartCandidates.mockReturnValue([])
mockSelectBestTrigger.mockReturnValue([])
vi.stubGlobal('fetch', mockFetch)
- mockRunUploadStrategy.mockRejectedValue(
- new DirectUploadErrorMock('Server signaled fallback to API upload', 'FALLBACK_REQUIRED')
+ mockUploadInternalFileSession.mockRejectedValue(
+ new Error('Workspace file storage limit exceeded')
)
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ error: 'Workspace file storage limit exceeded' }), {
@@ -378,12 +365,14 @@ describe('useWorkflowExecution attachment uploads', () => {
const file = new File(['report'], 'report.pdf', { type: 'application/pdf' })
let uploadError: unknown
- mockRunUploadStrategy.mockResolvedValueOnce({
+ mockUploadInternalFileSession.mockResolvedValueOnce({
+ id: 'attachment-context',
key: 'executions/context.txt',
- path: '/uploads/context.txt',
+ url: '/uploads/context.txt',
name: contextFile.name,
size: contextFile.size,
- contentType: contextFile.type,
+ type: contextFile.type,
+ context: 'execution',
})
await act(async () => {
@@ -437,12 +426,14 @@ describe('useWorkflowExecution attachment uploads', () => {
}
let runResult: unknown
- mockRunUploadStrategy.mockResolvedValueOnce({
+ mockUploadInternalFileSession.mockResolvedValueOnce({
+ id: 'attachment-diagram',
key: 'execution/diagram.png',
- path: '/api/files/serve/execution%2Fdiagram.png',
+ url: '/api/files/serve/execution%2Fdiagram.png',
name: file.name,
size: file.size,
- contentType: file.type,
+ type: file.type,
+ context: 'execution',
})
await act(async () => {
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-attachment-upload.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-attachment-upload.ts
index 938366c71bb..48a77406ed5 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-attachment-upload.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-attachment-upload.ts
@@ -1,11 +1,5 @@
import { getErrorMessage } from '@sim/utils/errors'
-import { generateShortId } from '@sim/utils/id'
-import { isRecordLike } from '@sim/utils/object'
-import {
- type ApiFallbackUploadMetadata,
- uploadViaApiFallbackWithMetadata,
-} from '@/lib/uploads/client/api-fallback'
-import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload'
+import { uploadInternalFileSession } from '@/lib/uploads/client/session-upload'
export interface WorkflowAttachmentInput {
name: string
@@ -33,39 +27,6 @@ interface UploadWorkflowAttachmentsParams {
executionId: string
}
-function getOptionalString(value: unknown): string | undefined {
- if (typeof value !== 'string') return undefined
- const trimmed = value.trim()
- return trimmed || undefined
-}
-
-function getDirectUploadFailureReason(error: unknown): string {
- if (error instanceof DirectUploadError && isRecordLike(error.details)) {
- const message =
- getOptionalString(error.details.message) ?? getOptionalString(error.details.error)
- if (message) return message
- }
-
- return getErrorMessage(error, 'Unknown upload error')
-}
-
-function normalizeFallbackUpload(
- value: ApiFallbackUploadMetadata,
- fallbackFile: WorkflowAttachmentInput
-): UploadedWorkflowAttachment {
- return {
- id: value.id ?? `file_${Date.now()}_${generateShortId(7)}`,
- name: value.name ?? fallbackFile.name,
- url: value.path,
- size: typeof value.size === 'number' ? value.size : fallbackFile.size,
- type: value.type ?? fallbackFile.type,
- key: value.key,
- context: 'execution',
- uploadedAt: value.uploadedAt,
- expiresAt: value.expiresAt,
- }
-}
-
/**
* Uploads every explicit workflow attachment before execution may begin.
*
@@ -78,46 +39,21 @@ export async function uploadWorkflowAttachments({
executionId,
}: UploadWorkflowAttachmentsParams): Promise {
const uploadedFiles: UploadedWorkflowAttachment[] = []
- const presignedEndpoint = `/api/files/presigned?type=execution&workflowId=${encodeURIComponent(workflowId)}&executionId=${encodeURIComponent(executionId)}&workspaceId=${encodeURIComponent(workspaceId)}`
for (const fileData of files) {
try {
- const result = await runUploadStrategy({
+ const result = await uploadInternalFileSession({
+ purpose: 'execution_attachment',
file: fileData.file,
workspaceId,
- context: 'execution',
workflowId,
executionId,
- presignedEndpoint,
- })
- uploadedFiles.push({
- id: `file_${Date.now()}_${generateShortId(7)}`,
- name: fileData.file.name,
- url: result.path,
- size: fileData.file.size,
- type: fileData.file.type,
- key: result.key,
- context: 'execution',
})
+ uploadedFiles.push(result)
} catch (uploadError) {
- if (!(uploadError instanceof DirectUploadError) || uploadError.code !== 'FALLBACK_REQUIRED') {
- throw new Error(
- `Failed to upload ${fileData.name}: ${getDirectUploadFailureReason(uploadError)}`
- )
- }
-
- try {
- const fallbackResult = await uploadViaApiFallbackWithMetadata(fileData.file, 'execution', {
- workflowId,
- executionId,
- workspaceId,
- })
- uploadedFiles.push(normalizeFallbackUpload(fallbackResult, fileData))
- } catch (error) {
- throw new Error(
- `Failed to upload ${fileData.name}: ${getErrorMessage(error, 'Network error')}`
- )
- }
+ throw new Error(
+ `Failed to upload ${fileData.name}: ${getErrorMessage(uploadError, 'Network error')}`
+ )
}
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts
index 0d589d24996..a902e2f18bb 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts
@@ -1,8 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
-import { uploadViaApiFallback } from '@/lib/uploads/client/api-fallback'
-import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload'
+import { uploadInternalFileSession } from '@/lib/uploads/client/session-upload'
const logger = createLogger('WorkspaceLogoUpload')
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
@@ -68,25 +67,13 @@ export function useWorkspaceLogoUpload({
throw new Error('workspaceId is required for workspace logo upload')
}
- const presignedEndpoint = `/api/files/presigned?type=workspace-logos&workspaceId=${encodeURIComponent(targetWorkspaceId)}`
-
- try {
- const result = await runUploadStrategy({
- file,
- workspaceId: targetWorkspaceId,
- context: 'workspace-logos',
- presignedEndpoint,
- })
- logger.info(`Workspace logo uploaded successfully: ${result.path}`)
- return result.path
- } catch (error) {
- if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') {
- const { path } = await uploadViaApiFallback(file, 'workspace-logos', targetWorkspaceId)
- logger.info(`Workspace logo uploaded via API fallback: ${path}`)
- return path
- }
- throw error
- }
+ const result = await uploadInternalFileSession({
+ purpose: 'workspace_logo',
+ file,
+ workspaceId: targetWorkspaceId,
+ })
+ logger.info(`Workspace logo uploaded successfully: ${result.path}`)
+ return result.path
}, [])
const processFile = useCallback(
diff --git a/apps/sim/background/cleanup-soft-deletes.ts b/apps/sim/background/cleanup-soft-deletes.ts
index 42bf64f7237..0dc4b025fea 100644
--- a/apps/sim/background/cleanup-soft-deletes.ts
+++ b/apps/sim/background/cleanup-soft-deletes.ts
@@ -113,7 +113,9 @@ async function selectExpiredWorkspaceFiles(
key: workspaceFiles.key,
workspaceId: workspaceFiles.workspaceId,
context: workspaceFiles.context,
- size: workspaceFiles.size,
+ size: sql`coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size})`.mapWith(
+ Number
+ ),
})
.from(workspaceFiles)
.where(
@@ -325,7 +327,12 @@ async function deleteExpiredBillableWorkspaceFileRows(
lt(workspaceFiles.deletedAt, retentionDate)
)
)
- .returning({ id: workspaceFiles.id, size: workspaceFiles.size })
+ .returning({
+ id: workspaceFiles.id,
+ size: sql`coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size})`.mapWith(
+ Number
+ ),
+ })
if (deletedRows.some(({ size }) => size < 0)) {
throw new Error('Cannot delete workspace files with negative stored-byte metadata')
}
@@ -695,14 +702,13 @@ const CLEANUP_TARGETS = [
] as const
/**
- * Sweep abandoned knowledge-base ownership bindings. The presigned upload flow
- * writes a `workspace_files` binding when it hands out an upload URL, before the
- * object is stored and before any document is created. If the upload is never
- * completed, that binding is orphaned — no `document.storageKey` ever references
- * its key. Such bindings are inert (read access requires a live document, and
- * the move re-point only follows referenced keys), but they accumulate, so we
- * drop the best-effort object and soft-delete the binding once they are older
- * than the grace window.
+ * Sweep abandoned knowledge-base ownership bindings. Knowledge upload sessions write a
+ * `workspace_files` binding before the object is stored and before any document is created.
+ * If the upload is never completed, that binding is orphaned — no
+ * `document.storageKey` ever references its key. Such bindings are inert (read access requires
+ * a live document, and the move re-point only follows referenced keys), but they accumulate,
+ * so we drop the best-effort object and soft-delete the binding once they are older than the
+ * grace window.
*/
async function cleanupOrphanedKnowledgeBaseBindings(
workspaceIds: string[],
diff --git a/apps/sim/ee/workspace-forking/lib/copy/storage-quota.ts b/apps/sim/ee/workspace-forking/lib/copy/storage-quota.ts
index 760cd187c76..f936afaa055 100644
--- a/apps/sim/ee/workspace-forking/lib/copy/storage-quota.ts
+++ b/apps/sim/ee/workspace-forking/lib/copy/storage-quota.ts
@@ -48,7 +48,7 @@ export async function sumForkCopyBytes(
fileSelectors.length === 0
? sql`0`
: sql