From 98c85677f50e0752b6af336c84f27673776ac859 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 29 Jun 2026 15:18:51 -0700 Subject: [PATCH 01/24] improvement(external-endpoints): v2 versions with clean signatures + updated docs --- .../docs/de/api-reference/getting-started.mdx | 2 +- .../content/docs/de/api-reference/meta.json | 9 +- .../(generated)/execution/meta.json | 3 + .../(generated)/workflows/meta.json | 6 +- .../docs/en/api-reference/getting-started.mdx | 2 +- .../content/docs/en/api-reference/meta.json | 8 +- .../en/platform/enterprise/audit-logs.mdx | 13 +- .../docs/es/api-reference/getting-started.mdx | 2 +- .../content/docs/es/api-reference/meta.json | 11 +- .../docs/fr/api-reference/getting-started.mdx | 2 +- .../content/docs/fr/api-reference/meta.json | 11 +- .../docs/ja/api-reference/getting-started.mdx | 2 +- .../content/docs/ja/api-reference/meta.json | 11 +- .../docs/zh/api-reference/getting-started.mdx | 2 +- .../content/docs/zh/api-reference/meta.json | 11 +- apps/docs/lib/openapi.ts | 80 +- apps/docs/openapi-core.json | 2948 +++++++++++++++++ apps/docs/openapi-v2-files-audit.json | 1125 +++++++ apps/docs/openapi-v2-knowledge.json | 1802 ++++++++++ apps/docs/openapi-v2-logs.json | 1065 ++++++ apps/docs/openapi-v2-tables.json | 2339 +++++++++++++ apps/docs/openapi-v2-workflows.json | 1024 ++++++ apps/sim/app/api/v1/admin/audit-logs/route.ts | 11 +- .../admin/organizations/[id]/billing/route.ts | 3 +- .../[id]/members/[memberId]/route.ts | 3 +- .../api/v1/admin/outbox/[id]/requeue/route.ts | 5 +- apps/sim/app/api/v1/admin/outbox/route.ts | 5 +- .../api/v1/admin/referral-campaigns/route.ts | 3 +- apps/sim/app/api/v1/audit-logs/auth.ts | 70 +- apps/sim/app/api/v1/logs/filters.ts | 12 +- apps/sim/app/api/v1/logs/route.ts | 2 +- apps/sim/app/api/v1/middleware.ts | 73 +- apps/sim/app/api/v2/audit-logs/[id]/route.ts | 76 + apps/sim/app/api/v2/audit-logs/route.ts | 103 + apps/sim/app/api/v2/files/[fileId]/route.ts | 124 + apps/sim/app/api/v2/files/route.ts | 236 ++ .../[id]/documents/[documentId]/route.ts | 209 ++ .../api/v2/knowledge/[id]/documents/route.ts | 306 ++ apps/sim/app/api/v2/knowledge/[id]/route.ts | 193 ++ apps/sim/app/api/v2/knowledge/route.ts | 140 + apps/sim/app/api/v2/knowledge/search/route.ts | 299 ++ apps/sim/app/api/v2/lib/response.ts | 144 + apps/sim/app/api/v2/logs/[id]/route.ts | 109 + .../v2/logs/executions/[executionId]/route.ts | 74 + apps/sim/app/api/v2/logs/route.ts | 168 + .../api/v2/tables/[tableId]/columns/route.ts | 269 ++ apps/sim/app/api/v2/tables/[tableId]/route.ts | 114 + .../v2/tables/[tableId]/rows/[rowId]/route.ts | 226 ++ .../app/api/v2/tables/[tableId]/rows/route.ts | 406 +++ .../v2/tables/[tableId]/rows/upsert/route.ts | 98 + apps/sim/app/api/v2/tables/route.ts | 140 + apps/sim/app/api/v2/tables/utils.ts | 103 + .../app/api/v2/workflows/[id]/deploy/route.ts | 169 + .../api/v2/workflows/[id]/rollback/route.ts | 122 + apps/sim/app/api/v2/workflows/[id]/route.ts | 81 + apps/sim/app/api/v2/workflows/route.ts | 142 + .../api/contracts/v1/admin/organizations.ts | 9 +- apps/sim/lib/api/contracts/v1/audit-logs.ts | 70 +- apps/sim/lib/api/contracts/v1/shared.ts | 44 + apps/sim/lib/api/contracts/v2/audit-logs.ts | 58 + apps/sim/lib/api/contracts/v2/files.ts | 112 + apps/sim/lib/api/contracts/v2/knowledge.ts | 270 ++ apps/sim/lib/api/contracts/v2/logs.ts | 123 + apps/sim/lib/api/contracts/v2/shared.ts | 39 + apps/sim/lib/api/contracts/v2/tables.ts | 321 ++ apps/sim/lib/api/contracts/v2/workflows.ts | 112 + .../orchestration/file-folder-lifecycle.ts | 10 +- bun.lock | 11 +- scripts/check-api-validation-contracts.ts | 4 +- 69 files changed, 15754 insertions(+), 145 deletions(-) create mode 100644 apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json create mode 100644 apps/docs/openapi-core.json create mode 100644 apps/docs/openapi-v2-files-audit.json create mode 100644 apps/docs/openapi-v2-knowledge.json create mode 100644 apps/docs/openapi-v2-logs.json create mode 100644 apps/docs/openapi-v2-tables.json create mode 100644 apps/docs/openapi-v2-workflows.json create mode 100644 apps/sim/app/api/v2/audit-logs/[id]/route.ts create mode 100644 apps/sim/app/api/v2/audit-logs/route.ts create mode 100644 apps/sim/app/api/v2/files/[fileId]/route.ts create mode 100644 apps/sim/app/api/v2/files/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/[id]/documents/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/[id]/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/search/route.ts create mode 100644 apps/sim/app/api/v2/lib/response.ts create mode 100644 apps/sim/app/api/v2/logs/[id]/route.ts create mode 100644 apps/sim/app/api/v2/logs/executions/[executionId]/route.ts create mode 100644 apps/sim/app/api/v2/logs/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/columns/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/rows/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts create mode 100644 apps/sim/app/api/v2/tables/route.ts create mode 100644 apps/sim/app/api/v2/tables/utils.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/deploy/route.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/rollback/route.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/route.ts create mode 100644 apps/sim/app/api/v2/workflows/route.ts create mode 100644 apps/sim/lib/api/contracts/v1/shared.ts create mode 100644 apps/sim/lib/api/contracts/v2/audit-logs.ts create mode 100644 apps/sim/lib/api/contracts/v2/files.ts create mode 100644 apps/sim/lib/api/contracts/v2/knowledge.ts create mode 100644 apps/sim/lib/api/contracts/v2/logs.ts create mode 100644 apps/sim/lib/api/contracts/v2/shared.ts create mode 100644 apps/sim/lib/api/contracts/v2/tables.ts create mode 100644 apps/sim/lib/api/contracts/v2/workflows.ts 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..74cedc72725 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,13 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", "(generated)/audit-logs", "(generated)/tables", - "(generated)/files" + "(generated)/files", + "(generated)/knowledge-bases", + "---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 491129e3cdf..6fb5dc0f8bf 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,13 +1,9 @@ { "pages": [ - "executeWorkflow", - "getWorkflowExecution", - "cancelExecution", "listWorkflows", "getWorkflow", "deployWorkflow", "undeployWorkflow", - "rollbackWorkflow", - "getJobStatus" + "rollbackWorkflow" ] } 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..74cedc72725 100644 --- a/apps/docs/content/docs/en/api-reference/meta.json +++ b/apps/docs/content/docs/en/api-reference/meta.json @@ -10,12 +10,14 @@ "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", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } 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/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..74cedc72725 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,13 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "---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..74cedc72725 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,13 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "---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..74cedc72725 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,13 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "---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..74cedc72725 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,13 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "---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..41f0687139a 100644 --- a/apps/docs/lib/openapi.ts +++ b/apps/docs/lib/openapi.ts @@ -2,8 +2,17 @@ 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', +] as const + export const openapi = createOpenAPI({ - input: ['./openapi.json'], + input: SPEC_FILES.map((file) => `./${file}`), }) interface OpenAPIOperation { @@ -24,20 +33,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 +71,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 +106,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..53a99c2e866 --- /dev/null +++ b/apps/docs/openapi-core.json @@ -0,0 +1,2948 @@ +{ + "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 rate limits, 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 rate limits, usage, and storage information.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsageLimits" + }, + "example": { + "success": true, + "rateLimit": { + "sync": { + "limit": 100, + "remaining": 95, + "reset": "2026-01-15T11:00:00Z" + }, + "async": { + "limit": 50, + "remaining": 48, + "reset": "2026-01-15T11:00:00Z" + } + }, + "usage": { + "currentPeriodCost": 12.5, + "limit": 100, + "plan": "pro" + }, + "storage": { + "usedBytes": 5242880, + "limitBytes": 1073741824, + "percentUsed": 0.49 + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "parameters": [] + } + } + }, + "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": { + "ColumnDefinition": { + "type": "object", + "description": "Definition of a table column including its type and constraints.", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "description": "Column name. Must start with a letter or underscore.", + "example": "email", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$" + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "date", "json"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "description": "Whether the column requires a value on insert.", + "default": false + }, + "unique": { + "type": "boolean", + "description": "Whether values in this column must be unique across all rows.", + "default": false + } + } + }, + "Table": { + "type": "object", + "description": "A user-defined table with a typed schema.", + "properties": { + "id": { + "type": "string", + "description": "Unique table identifier.", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "name": { + "type": "string", + "description": "Table name.", + "example": "contacts" + }, + "description": { + "type": "string", + "description": "Optional description of the table.", + "example": "Customer contact records" + }, + "schema": { + "type": "object", + "description": "Table schema definition.", + "properties": { + "columns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ColumnDefinition" + }, + "description": "Array of column definitions for the table." + } + } + }, + "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." + } + } + }, + "TableRow": { + "type": "object", + "description": "A single row in a table.", + "properties": { + "id": { + "type": "string", + "description": "Unique row identifier.", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "data": { + "type": "object", + "additionalProperties": true, + "description": "Row data as key-value pairs matching the table schema." + }, + "position": { + "type": "integer", + "description": "Row's position/order in the table." + }, + "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." + } + } + }, + "WorkflowSummary": { + "type": "object", + "description": "Summary representation of a workflow returned in list operations.", + "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.", + "example": "Routes incoming support tickets and drafts responses" + }, + "folderId": { + "type": "string", + "nullable": true, + "description": "The folder this workflow belongs to. null if at the workspace root.", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + }, + "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 if never deployed.", + "example": "2025-06-15T10:30:00Z" + }, + "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 if never run.", + "example": "2025-06-20T14:15:22Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was created.", + "example": "2025-01-10T09:00:00Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was last modified.", + "example": "2025-06-18T16:45:00Z" + } + } + }, + "WorkflowDetail": { + "type": "object", + "description": "Full workflow representation including input field definitions and configuration.", + "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.", + "example": "Routes incoming support tickets and drafts responses" + }, + "folderId": { + "type": "string", + "nullable": true, + "description": "The folder this workflow belongs to. null if at the workspace root.", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + }, + "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 if never deployed.", + "example": "2025-06-15T10:30:00Z" + }, + "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 if never run.", + "example": "2025-06-20T14:15:22Z" + }, + "variables": { + "type": "object", + "description": "Workflow-level variables and their current values.", + "example": {} + }, + "inputs": { + "type": "object", + "description": "The workflow's input field definitions. Use these to construct the input object when executing the workflow.", + "properties": { + "fields": { + "type": "object", + "description": "Map of field names to their type definitions and configuration.", + "additionalProperties": true, + "example": {} + } + } + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was created.", + "example": "2025-01-10T09:00:00Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was last modified.", + "example": "2025-06-18T16:45:00Z" + } + } + }, + "WorkflowDeployment": { + "type": "object", + "description": "Deployment state of a workflow after a deploy, undeploy, or rollback operation.", + "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.", + "example": true + }, + "deployedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the active deployment. null after an undeploy.", + "example": "2026-06-12T10:30:00Z" + }, + "version": { + "type": "integer", + "description": "The deployment version that is now active. Omitted for undeploy.", + "example": 4 + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-fatal warnings. Present when trigger, schedule, or MCP side-effect sync is still in progress or needs a redeploy." + } + } + }, + "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" + } + } + }, + "LogEntry": { + "type": "object", + "description": "Summary of a single workflow execution log entry.", + "properties": { + "id": { + "type": "string", + "description": "Unique log entry identifier.", + "example": "log_7x8y9z0a1b" + }, + "workflowId": { + "type": "string", + "description": "The workflow that was executed.", + "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, manual, webhook, schedule, chat).", + "example": "api" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2025-06-20T14:15:22Z" + }, + "endedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed.", + "example": "2025-06-20T14:15:23Z" + }, + "totalDurationMs": { + "type": "integer", + "description": "Total execution duration in milliseconds.", + "example": 1250 + }, + "cost": { + "type": "object", + "description": "Cost summary for this execution.", + "properties": { + "total": { + "type": "number", + "description": "Total cost of this execution in USD.", + "example": 0.0032 + } + } + }, + "files": { + "type": "object", + "nullable": true, + "description": "File outputs produced during execution. null if no files were generated.", + "example": null + } + } + }, + "LogDetail": { + "type": "object", + "description": "Detailed log entry with full execution data, workflow metadata, and cost breakdown.", + "properties": { + "id": { + "type": "string", + "description": "Unique log entry identifier.", + "example": "log_7x8y9z0a1b" + }, + "workflowId": { + "type": "string", + "description": "The workflow that was executed.", + "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, manual, webhook, schedule, chat).", + "example": "api" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2025-06-20T14:15:22Z" + }, + "endedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed.", + "example": "2025-06-20T14:15:23Z" + }, + "totalDurationMs": { + "type": "integer", + "description": "Total execution duration in milliseconds.", + "example": 1250 + }, + "workflow": { + "type": "object", + "description": "Summary metadata about the workflow at the time of execution.", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Workflow name at the time of execution.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Workflow description at the time of execution.", + "example": "Routes incoming support tickets and drafts responses" + } + } + }, + "executionData": { + "type": "object", + "description": "Detailed execution data including block-level traces and final output.", + "properties": { + "traceSpans": { + "type": "array", + "description": "Block-level execution traces with timing, inputs, and outputs for each block that ran.", + "items": { + "type": "object" + } + }, + "finalOutput": { + "type": "object", + "description": "The workflow's final output after all blocks completed." + } + } + }, + "cost": { + "type": "object", + "description": "Detailed cost breakdown for this execution.", + "properties": { + "total": { + "type": "number", + "description": "Total cost of this execution in USD.", + "example": 0.0032 + }, + "tokens": { + "type": "object", + "description": "Aggregate token usage across all AI model calls in this execution.", + "properties": { + "prompt": { + "type": "integer", + "description": "Total prompt (input) tokens consumed.", + "example": 450 + }, + "completion": { + "type": "integer", + "description": "Total completion (output) tokens generated.", + "example": 120 + }, + "total": { + "type": "integer", + "description": "Total tokens (prompt + completion).", + "example": 570 + } + } + }, + "models": { + "type": "object", + "description": "Per-model cost and token breakdown. Keys are model identifiers (e.g., gpt-4o, claude-sonnet-4-20250514).", + "additionalProperties": { + "type": "object", + "description": "Cost and token details for a specific model.", + "properties": { + "input": { + "type": "number", + "description": "Cost of prompt tokens for this model in USD." + }, + "output": { + "type": "number", + "description": "Cost of completion tokens for this model in USD." + }, + "total": { + "type": "number", + "description": "Total cost for this model in USD." + }, + "tokens": { + "type": "object", + "description": "Token usage for this specific model.", + "properties": { + "prompt": { + "type": "integer", + "description": "Prompt tokens consumed by this model." + }, + "completion": { + "type": "integer", + "description": "Completion tokens generated by this model." + }, + "total": { + "type": "integer", + "description": "Total tokens for this model." + } + } + } + } + } + } + } + } + } + }, + "Limits": { + "type": "object", + "description": "Rate limit and usage information included in every API response.", + "properties": { + "workflowExecutionRateLimit": { + "type": "object", + "description": "Current rate limit status for workflow executions.", + "properties": { + "sync": { + "description": "Rate limit bucket for synchronous executions.", + "$ref": "#/components/schemas/RateLimitBucket" + }, + "async": { + "description": "Rate limit bucket for asynchronous executions.", + "$ref": "#/components/schemas/RateLimitBucket" + } + } + }, + "usage": { + "type": "object", + "description": "Current billing period usage and plan limits.", + "properties": { + "currentPeriodCost": { + "type": "number", + "description": "Total spend in the current billing period in USD.", + "example": 1.25 + }, + "limit": { + "type": "number", + "description": "Maximum allowed spend for the current billing period in USD.", + "example": 50 + }, + "plan": { + "type": "string", + "description": "Your current subscription plan (e.g., free, pro, team).", + "example": "pro" + }, + "isExceeded": { + "type": "boolean", + "description": "Whether the usage limit has been exceeded. Executions may be blocked when true.", + "example": false + } + } + } + } + }, + "RateLimitBucket": { + "type": "object", + "description": "Rate limit status for a specific execution type.", + "properties": { + "requestsPerMinute": { + "type": "integer", + "description": "Maximum number of requests allowed per minute.", + "example": 60 + }, + "maxBurst": { + "type": "integer", + "description": "Maximum number of concurrent requests allowed in a burst.", + "example": 10 + }, + "remaining": { + "type": "integer", + "description": "Number of requests remaining in the current rate limit window.", + "example": 59 + }, + "resetAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the rate limit window resets.", + "example": "2025-06-20T14:16:00Z" + } + } + }, + "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" + } + } + } + }, + "AuditLogEntry": { + "type": "object", + "description": "An enterprise audit log entry recording an action taken in the workspace.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the audit log entry.", + "example": "audit_2c3d4e5f6g" + }, + "workspaceId": { + "type": "string", + "nullable": true, + "description": "The workspace where the action occurred.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "actorId": { + "type": "string", + "nullable": true, + "description": "The user ID of the person who performed the action.", + "example": "user_abc123" + }, + "actorName": { + "type": "string", + "nullable": true, + "description": "Display name of the person who performed the action.", + "example": "Jane Smith" + }, + "actorEmail": { + "type": "string", + "nullable": true, + "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., workflow.created, member.invited).", + "example": "workflow.deployed" + }, + "resourceType": { + "type": "string", + "description": "The type of resource affected (e.g., workflow, workspace, member).", + "example": "workflow" + }, + "resourceId": { + "type": "string", + "nullable": true, + "description": "The unique identifier of the affected resource.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "resourceName": { + "type": "string", + "nullable": true, + "description": "Display name of the affected resource.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Human-readable description of the action.", + "example": "Deployed workflow Customer Support Agent" + }, + "metadata": { + "type": "object", + "nullable": true, + "description": "Additional context about the action.", + "example": null + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the action occurred.", + "example": "2025-06-20T14:15:22Z" + } + } + }, + "UsageLimits": { + "type": "object", + "description": "Current rate limits, usage, and storage information for the authenticated user.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the request was successful." + }, + "rateLimit": { + "type": "object", + "description": "Rate limit status for workflow executions.", + "properties": { + "sync": { + "description": "Rate limit bucket for synchronous executions.", + "allOf": [ + { + "$ref": "#/components/schemas/RateLimitBucket" + }, + { + "type": "object", + "properties": { + "isLimited": { + "type": "boolean", + "description": "Whether the rate limit has been reached." + } + } + } + ] + }, + "async": { + "description": "Rate limit bucket for asynchronous executions.", + "allOf": [ + { + "$ref": "#/components/schemas/RateLimitBucket" + }, + { + "type": "object", + "properties": { + "isLimited": { + "type": "boolean", + "description": "Whether the rate limit has been reached." + } + } + } + ] + }, + "authType": { + "type": "string", + "description": "The authentication type used (api or manual)." + } + } + }, + "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)." + } + } + } + } + }, + "FileMetadata": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique file identifier.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "name": { + "type": "string", + "description": "Original filename.", + "example": "data.csv" + }, + "size": { + "type": "integer", + "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/abc-123/1709571234-xyz-data.csv" + }, + "uploadedBy": { + "type": "string", + "description": "User ID of the uploader." + }, + "uploadedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of when the file was uploaded." + } + } + }, + "KnowledgeBase": { + "type": "object", + "description": "A knowledge base for storing and searching document embeddings.", + "properties": { + "id": { + "type": "string", + "description": "Unique knowledge base identifier." + }, + "name": { + "type": "string", + "description": "Knowledge base name." + }, + "description": { + "type": "string", + "nullable": true, + "description": "Optional description." + }, + "tokenCount": { + "type": "integer", + "description": "Total token count across all documents." + }, + "embeddingModel": { + "type": "string", + "description": "Embedding model used (e.g. text-embedding-3-small)." + }, + "embeddingDimension": { + "type": "integer", + "description": "Embedding vector dimension." + }, + "chunkingConfig": { + "$ref": "#/components/schemas/ChunkingConfig" + }, + "docCount": { + "type": "integer", + "description": "Number of documents in the knowledge base." + }, + "connectorTypes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Types of connectors attached to this knowledge base." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the knowledge base was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the knowledge base was last modified." + } + } + }, + "ChunkingConfig": { + "type": "object", + "description": "Configuration for how documents are split into chunks for embedding.", + "properties": { + "maxSize": { + "type": "integer", + "minimum": 100, + "maximum": 4000, + "default": 1024, + "description": "Maximum chunk size in tokens." + }, + "minSize": { + "type": "integer", + "minimum": 1, + "maximum": 2000, + "default": 100, + "description": "Minimum chunk size in characters." + }, + "overlap": { + "type": "integer", + "minimum": 0, + "maximum": 500, + "default": 200, + "description": "Overlap between chunks in tokens." + } + } + }, + "KnowledgeDocument": { + "type": "object", + "description": "A document in a knowledge base.", + "properties": { + "id": { + "type": "string", + "description": "Unique document identifier." + }, + "knowledgeBaseId": { + "type": "string", + "description": "Knowledge base this document belongs to." + }, + "filename": { + "type": "string", + "description": "Original filename." + }, + "fileSize": { + "type": "integer", + "description": "File size in bytes." + }, + "mimeType": { + "type": "string", + "description": "MIME type of the file." + }, + "processingStatus": { + "type": "string", + "enum": ["pending", "processing", "completed", "failed"], + "description": "Current processing status." + }, + "chunkCount": { + "type": "integer", + "description": "Number of chunks created from this document." + }, + "tokenCount": { + "type": "integer", + "description": "Total token count." + }, + "characterCount": { + "type": "integer", + "description": "Total character count." + }, + "enabled": { + "type": "boolean", + "description": "Whether the document is enabled for search." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the document was uploaded." + } + } + }, + "KnowledgeDocumentDetail": { + "type": "object", + "description": "Detailed document information including processing and connector details.", + "properties": { + "id": { + "type": "string", + "description": "Unique document identifier." + }, + "knowledgeBaseId": { + "type": "string", + "description": "Knowledge base this document belongs to." + }, + "filename": { + "type": "string", + "description": "Original filename." + }, + "fileSize": { + "type": "integer", + "description": "File size in bytes." + }, + "mimeType": { + "type": "string", + "description": "MIME type of the file." + }, + "processingStatus": { + "type": "string", + "enum": ["pending", "processing", "completed", "failed"], + "description": "Current processing status." + }, + "processingError": { + "type": "string", + "nullable": true, + "description": "Error message if processing failed." + }, + "processingStartedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When processing started." + }, + "processingCompletedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When processing completed." + }, + "chunkCount": { + "type": "integer", + "description": "Number of chunks created." + }, + "tokenCount": { + "type": "integer", + "description": "Total token count." + }, + "characterCount": { + "type": "integer", + "description": "Total character count." + }, + "enabled": { + "type": "boolean", + "description": "Whether the document is enabled for search." + }, + "connectorId": { + "type": "string", + "nullable": true, + "description": "Connector ID if sourced from an external connector." + }, + "connectorType": { + "type": "string", + "nullable": true, + "description": "Connector type (e.g. google-drive, notion)." + }, + "sourceUrl": { + "type": "string", + "nullable": true, + "description": "Original source URL for connector-sourced documents." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the document was uploaded." + } + } + }, + "SearchResult": { + "type": "object", + "description": "A single search result from knowledge base vector search.", + "properties": { + "documentId": { + "type": "string", + "description": "ID of the source document." + }, + "documentName": { + "type": "string", + "description": "Filename of the source document." + }, + "sourceUrl": { + "type": "string", + "nullable": true, + "description": "URL to the original source document for connector-synced documents (e.g., a Confluence page, Google Doc, or Notion page). Null for documents without an external source." + }, + "content": { + "type": "string", + "description": "The matched chunk content." + }, + "chunkIndex": { + "type": "integer", + "description": "Index of the chunk within the document." + }, + "metadata": { + "type": "object", + "description": "Tag metadata associated with the chunk (display names mapped to values)." + }, + "similarity": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Similarity score (0-1, where 1 is most similar)." + } + } + }, + "TagFilter": { + "type": "object", + "description": "A tag-based filter for knowledge base search.", + "required": ["tagName", "value"], + "properties": { + "tagName": { + "type": "string", + "description": "Display name of the tag to filter by." + }, + "fieldType": { + "type": "string", + "enum": ["text", "number", "date", "boolean"], + "default": "text", + "description": "Data type of the tag field." + }, + "operator": { + "type": "string", + "default": "eq", + "description": "Comparison operator (e.g. eq, neq, gt, lt, gte, lte, contains, between)." + }, + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "description": "Value to filter by." + }, + "valueTo": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ], + "description": "Upper bound value for 'between' operator." + } + } + }, + "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." + } + } + } + } + } + }, + "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" + ] + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json new file mode 100644 index 00000000000..402866bc262 --- /dev/null +++ b/apps/docs/openapi-v2-files-audit.json @@ -0,0 +1,1125 @@ +{ + "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, and archive workspace files (v2). Workspace-scoped via the required workspaceId query parameter." + }, + { + "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 the active files in a workspace with opaque cursor pagination. Results are ordered by upload time. Pass the `nextCursor` from a previous response to fetch the next page; a `null` `nextCursor` means there are no more results.", + "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" + } + ], + "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" + }, + "example": { + "data": [ + { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "name": "data.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", + "uploadedBy": "user_abc123", + "uploadedAt": "2026-01-15T10:30:00Z" + } + ], + "nextCursor": "eyJ1cGxvYWRlZEF0IjoiMjAyNi0wMS0xNVQxMDozMDowMFoiLCJpZCI6IndmX1YxU3RHWFI4ejVqZEhpNkJteVQ5MSJ9" + } + } + } + }, + "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": "uploadFile", + "summary": "Upload File", + "description": "Upload a file to a workspace as `multipart/form-data` with a single `file` field. The workspace is supplied as the `workspaceId` query parameter (not a form field) so authorization runs before the request body is buffered. Maximum file size is 100MB. Duplicate filenames within a workspace are rejected. Returns `201 Created`.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/files?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -F \"file=@/path/to/file.csv\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "requestBody": { + "required": true, + "description": "The file to upload, sent as multipart/form-data.", + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": ["file"], + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "The file to upload. Maximum size is 100MB." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The file was uploaded successfully.", + "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" + }, + "example": { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "name": "data.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", + "uploadedBy": "user_abc123", + "uploadedAt": "2026-01-15T10:30:00Z" + } + } + } + } + }, + "400": { + "description": "The request was malformed: an invalid `workspaceId` query parameter, a body that is not valid multipart form data, or a missing `file` form field.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "file form field is required" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "description": "A file with the same name already exists in this workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "A file with this name already exists in the workspace" + } + } + } + } + }, + "413": { + "description": "The upload exceeds the 100MB file size limit, or the workspace storage limit would be exceeded.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "File size exceeds 100MB limit (142.30MB)" + } + } + } + } + }, + "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", + "example": "text/csv" + } + }, + "Content-Disposition": { + "description": "Attachment disposition carrying the (sanitized and RFC 5987 encoded) filename.", + "schema": { + "type": "string", + "example": "attachment; filename=\"data.csv\"; filename*=UTF-8''data.csv" + } + }, + "Content-Length": { + "description": "Size of the file in bytes.", + "schema": { + "type": "string", + "example": "1024" + } + }, + "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": "Archive (soft 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 archived.", + "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" + }, + "example": { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "deleted": true + } + } + } + } + }, + "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 archived because of a conflicting state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "Failed to delete file" + } + } + } + } + }, + "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" + } + } + } + } + }, + "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" + } + }, + "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", "uploadedBy", "uploadedAt"], + "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" + } + } + }, + "V2DeleteFileResult": { + "type": "object", + "description": "Acknowledgement returned by a successful archive (soft delete).", + "required": ["id", "deleted"], + "properties": { + "id": { + "type": "string", + "description": "The unique identifier of the archived file.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Always true on a successful archive." + } + } + }, + "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." + } + } + } + } + } + }, + "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" + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json new file mode 100644 index 00000000000..5c43fd27ff7 --- /dev/null +++ b/apps/docs/openapi-v2-knowledge.json @@ -0,0 +1,1802 @@ +{ + "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" + } + ], + "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" + } + } + ], + "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" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "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" + } + } + } + }, + "/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" + } + } + }, + "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" + } + } + } + } + }, + "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" + } + }, + "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" + } + } + }, + "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"] + } + } + }, + "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" + ], + "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" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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" + } + } + } + }, + "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." + } + } + } + } + } + }, + "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..4631df64376 --- /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": "folderIds", + "in": "query", + "description": "Comma-separated list of folder IDs. 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", + "folderId": null, + "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", + "folderId", + "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" + }, + "folderId": { + "type": ["string", "null"], + "description": "The folder the workflow belongs to. null if at the workspace root or the workflow is gone.", + "example": null + }, + "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-tables.json b/apps/docs/openapi-v2-tables.json new file mode 100644 index 00000000000..fa3daf0cd97 --- /dev/null +++ b/apps/docs/openapi-v2-tables.json @@ -0,0 +1,2339 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim Tables API v2", + "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", + "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": "listTables", + "summary": "List Tables", + "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": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "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": { + "$ref": "#/components/schemas/TableListEnvelope" + }, + "example": { + "data": [ + { + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "contacts", + "description": "Customer contact records", + "schema": { + "columns": [ + { + "id": "col_a1b2c3", + "name": "email", + "type": "string", + "required": true, + "unique": true + }, + { + "id": "col_d4e5f6", + "name": "name", + "type": "string", + "required": true + } + ] + }, + "rowCount": 2, + "maxRows": 100000, + "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" + }, + "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" + }, + "example": { + "data": { + "table": { + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "contacts", + "description": "Customer contacts", + "schema": { + "columns": [ + { + "id": "col_a1b2c3", + "name": "email", + "type": "string", + "required": true, + "unique": true + }, + { + "id": "col_d4e5f6", + "name": "name", + "type": "string", + "required": true + }, + { + "id": "col_g7h8i9", + "name": "age", + "type": "number", + "required": false, + "unique": false + } + ] + }, + "rowCount": 0, + "maxRows": 100000, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + } + } + } + } + }, + "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}": { + "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": [ + { + "$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": "Archive a table. Returns the id of the archived table.", + "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 archived.", + "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" + }, + "example": { + "data": { + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + } + } + } + } + }, + "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}/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": "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": { + "$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": { + "$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": "Query rows from a table with optional filtering, sorting, and cursor pagination. `filter` and `sort` are passed as JSON-encoded query parameters and key on column names. Pagination uses an opaque cursor: pass the `nextCursor` from a previous response to fetch the next page; `nextCursor` is null on the final page. Total row count is available as `rowCount` on the table resource.", + "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/FilterQuery" + }, + { + "$ref": "#/components/parameters/SortQuery" + }, + { + "$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 + }, + "position": 0, + "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" + }, + "position": 0, + "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" + }, + "position": 0, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + }, + { + "id": "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85", + "data": { + "email": "b@example.com", + "name": "Babbage" + }, + "position": 1, + "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": { + "status": "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" + }, + "position": 0, + "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" + } + } + } + } + }, + "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." + }, + "FilterQuery": { + "name": "filter", + "in": "query", + "required": false, + "description": "JSON-encoded filter object keyed by column name. Supports equality ({\"status\": \"active\"}), comparison operators ({\"age\": {\"$gt\": 18}}), and $and/$or composition.", + "schema": { + "type": "string" + } + }, + "SortQuery": { + "name": "sort", + "in": "query", + "required": false, + "description": "JSON-encoded sort object mapping column name to direction. Example: {\"created_at\": \"desc\"}.", + "schema": { + "type": "string" + } + }, + "LimitQuery": { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum rows to return (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 + } + } + }, + "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", "boolean", "date", "json"], + "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." + } + } + }, + "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", "boolean", "date", "json"], + "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." + } + } + }, + "Table": { + "type": "object", + "description": "A user-defined table with a typed column schema.", + "required": [ + "id", + "name", + "description", + "schema", + "rowCount", + "maxRows", + "createdAt", + "updatedAt" + ], + "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." + } + } + }, + "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", "position", "createdAt", "updatedAt"], + "properties": { + "id": { + "type": "string", + "description": "Unique row identifier.", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "position": { + "type": "integer", + "description": "Row's position/order in the table." + }, + "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." + } + } + }, + "Filter": { + "type": "object", + "additionalProperties": true, + "minProperties": 1, + "description": "Filter object keyed by column name. Supports equality ({\"status\": \"active\"}), comparison operators ({\"age\": {\"$gt\": 18}}), and $and/$or composition. Must contain at least one condition.", + "example": { + "status": "active" + } + }, + "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": { + "$ref": "#/components/schemas/ColumnInput" + } + } + } + } + } + }, + "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": { + "type": "object", + "description": "The column definition to add.", + "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": "phone" + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "date", "json"], + "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." + }, + "position": { + "type": "integer", + "minimum": 0, + "description": "Zero-based insert position in the column order. Appended at the end when omitted." + } + } + } + } + }, + "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", "boolean", "date", "json"], + "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." + } + } + } + } + }, + "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/Filter" + }, + "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/Filter" + }, + "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 archived.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "description": "The id of the archived table." + } + } + } + } + }, + "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." + } + } + } + } + } + }, + "responses": { + "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/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { + "path": "schema.columns", + "message": "Table must have at least one column" + } + ] + } + } + } + } + }, + "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. 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/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Access denied" + } + } + } + } + }, + "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/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Table not found" + } + } + } + } + }, + "RateLimited": { + "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" + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json new file mode 100644 index 00000000000..341c14ceb5c --- /dev/null +++ b/apps/docs/openapi-v2-workflows.json @@ -0,0 +1,1024 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 — Workflows", + "description": "Version 2 of the Sim REST API for listing workflows, inspecting workflow detail, and managing 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": "List workflows, inspect workflow detail, 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": "folderId", + "in": "query", + "required": false, + "description": "Filter results to only include workflows within this folder.", + "schema": { + "type": "string", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + } + }, + { + "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" + } + } + ], + "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." + } + } + }, + "example": { + "data": [ + { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer Support Agent", + "description": "Routes incoming support tickets and drafts responses", + "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "runCount": 142, + "lastRunAt": "2026-06-20T14:15:22.000Z", + "createdAt": "2026-01-10T09:00:00.000Z", + "updatedAt": "2026-06-18T16:45:00.000Z" + } + ], + "nextCursor": "eyJzb3J0T3JkZXIiOjAsImNyZWF0ZWRBdCI6IjIwMjYtMDEtMTBUMDk6MDA6MDAuMDAwWiIsImlkIjoiM2IxZjdjOTIifQ==" + } + } + } + }, + "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/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" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer Support Agent", + "description": "Routes incoming support tickets and drafts responses", + "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "runCount": 142, + "lastRunAt": "2026-06-20T14:15:22.000Z", + "variables": { + "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60": { + "id": "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60", + "name": "supportEmail", + "type": "string", + "value": "support@example.com" + } + }, + "inputs": [ + { + "name": "ticketBody", + "type": "string", + "description": "The raw text of the incoming support ticket." + } + ], + "createdAt": "2026-01-10T09:00:00.000Z", + "updatedAt": "2026-06-18T16:45:00.000Z" + } + } + } + } + }, + "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": [] + } + } + } + } + }, + "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": [] + } + } + } + } + }, + "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": [] + } + } + } + } + }, + "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" + } + } + } + } + }, + "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" + } + } + }, + "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", + "folderId", + "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" + }, + "folderId": { + "type": "string", + "nullable": true, + "description": "The folder this workflow belongs to. `null` when at the workspace root.", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + }, + "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", + "folderId", + "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"], + "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" + } + } + } + }, + "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 + } + } + } + ] + } + }, + "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/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 0f25618fc2c..1c0913cf576 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 @@ -44,6 +44,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, @@ -143,7 +144,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 2b00537059a..bfe0bb747c7 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 @@ -71,7 +71,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 b01f6af9736..9adc8d5c29f 100644 --- a/apps/sim/app/api/v1/audit-logs/auth.ts +++ b/apps/sim/app/api/v1/audit-logs/auth.ts @@ -35,7 +35,21 @@ type AuthResult = * Returns the organization ID and all member user IDs on success, * or an error response on failure. */ -export async function validateEnterpriseAuditAccess(userId: string): Promise { +/** + * 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). See + * {@link validateEnterpriseAuditAccess} for the policy checks performed. + */ +export async function resolveEnterpriseAuditAccess( + userId: string +): Promise { const [membership] = await db .select({ organizationId: member.organizationId, role: member.role }) .from(member) @@ -43,31 +57,16 @@ export async function validateEnterpriseAuditAccess(userId: string): Promise m.userId) @@ -108,9 +101,26 @@ export async function validateEnterpriseAuditAccess(userId: string): Promise { + const result = await resolveEnterpriseAuditAccess(userId) + 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/logs/filters.ts b/apps/sim/app/api/v1/logs/filters.ts index 0e409e4d53f..8e40ca1db51 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 bd6a2185dd5..74f992fc207 100644 --- a/apps/sim/app/api/v1/logs/route.ts +++ b/apps/sim/app/api/v1/logs/route.ts @@ -124,7 +124,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 51d69070f32..d27385305f0 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -157,36 +157,46 @@ 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', + } } } @@ -194,21 +204,46 @@ export async function checkWorkspaceScope( } /** - * Validates workspace-scoped API key bounds and the user's workspace permission. - * Returns null on success, NextResponse on failure. + * 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 validateWorkspaceAccess( +export async function resolveWorkspaceAccess( rateLimit: RateLimitResult, userId: string, workspaceId: string, level: PermissionType = 'read' -): Promise { - const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) +): Promise { + const scopeError = await resolveWorkspaceScope(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 { 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 +} + +/** + * v1 wrapper: renders {@link resolveWorkspaceAccess} as the v1 `{ error }` body. + * Returns null on success, NextResponse on failure. + */ +export async function validateWorkspaceAccess( + rateLimit: RateLimitResult, + userId: string, + workspaceId: string, + level: PermissionType = 'read' +): Promise { + 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/v2/audit-logs/[id]/route.ts b/apps/sim/app/api/v2/audit-logs/[id]/route.ts new file mode 100644 index 00000000000..d1fca3d0aa0 --- /dev/null +++ b/apps/sim/app/api/v2/audit-logs/[id]/route.ts @@ -0,0 +1,76 @@ +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 { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' +import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query' +import { checkRateLimit } from '@/app/api/v1/middleware' +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 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(formatAuditLogEntry(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/route.ts b/apps/sim/app/api/v2/audit-logs/route.ts new file mode 100644 index 00000000000..c785ccaaede --- /dev/null +++ b/apps/sim/app/api/v2/audit-logs/route.ts @@ -0,0 +1,103 @@ +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 { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' +import { + buildFilterConditions, + buildOrgScopeCondition, + getOrgWorkspaceIds, + queryAuditLogs, +} from '@/app/api/v1/audit-logs/query' +import { checkRateLimit } from '@/app/api/v1/middleware' +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 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(formatAuditLogEntry), 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/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts new file mode 100644 index 00000000000..9d2e6d603b9 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -0,0 +1,124 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2DeleteFileContract, v2DownloadFileContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import type { V2ErrorCode } from '@/app/api/v2/lib/response' +import { + rateLimitHeaders, + v2Data, + v2Error, + 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 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') + } +}) + +/** + * DELETE /api/v2/files/[fileId] — Archive (soft 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 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) { + const code: V2ErrorCode = + result.errorCode === 'not_found' + ? 'NOT_FOUND' + : result.errorCode === 'validation' + ? 'BAD_REQUEST' + : result.errorCode === 'conflict' + ? 'CONFLICT' + : 'INTERNAL_ERROR' + return v2Error(code, result.error || 'Failed to delete file') + } + + logger.info(`Archived 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/route.ts b/apps/sim/app/api/v2/files/route.ts new file mode 100644 index 00000000000..dbc6e982068 --- /dev/null +++ b/apps/sim/app/api/v2/files/route.ts @@ -0,0 +1,236 @@ +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 { + type V2File, + v2ListFilesContract, + v2UploadFileContract, +} from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { + isPayloadSizeLimitError, + readFileToBufferWithLimit, + readFormDataWithLimit, +} from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + FileConflictError, + getWorkspaceFile, + listWorkspaceFiles, + uploadWorkspaceFile, +} from '@/lib/uploads/contexts/workspace' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FilesAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const MAX_FILE_SIZE = 100 * 1024 * 1024 +const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 + +interface FileCursor { + uploadedAt: string + id: string +} + +/** Stable keyset ordering: `uploadedAt` ascending, `id` ascending as the tiebreaker. */ +function compareFiles(a: V2File, b: V2File): number { + if (a.uploadedAt !== b.uploadedAt) return a.uploadedAt < b.uploadedAt ? -1 : 1 + if (a.id !== b.id) return a.id < b.id ? -1 : 1 + return 0 +} + +/** + * GET /api/v2/files — List files in a workspace with cursor pagination. + * + * The shared {@link listWorkspaceFiles} manager returns the full active set + * ordered by `uploadedAt`; v2 applies a bounded keyset slice over that result in + * the route. Pushing `limit`/`cursor` down into the manager query is a follow-up. + */ +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 parsed = await parseRequest( + v2ListFilesContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, limit, cursor } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const files = await listWorkspaceFiles(workspaceId) + + const items: V2File[] = files + .map((f) => ({ + id: f.id, + name: f.name, + size: f.size, + type: f.type, + key: f.key, + uploadedBy: f.uploadedBy, + uploadedAt: + f.uploadedAt instanceof Date ? f.uploadedAt.toISOString() : String(f.uploadedAt), + })) + .sort(compareFiles) + + const decoded = cursor ? decodeCursor(cursor) : null + const afterCursor = decoded + ? items.filter( + (f) => + f.uploadedAt > decoded.uploadedAt || + (f.uploadedAt === decoded.uploadedAt && f.id > decoded.id) + ) + : items + + const hasMore = afterCursor.length > limit + const page = afterCursor.slice(0, limit) + const last = page.at(-1) + const nextCursor = + hasMore && last ? encodeCursor({ uploadedAt: last.uploadedAt, id: last.id }) : null + + return v2CursorList(page, nextCursor, { rateLimit }) + } catch (error) { + logger.error('Error listing files', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * POST /api/v2/files — Upload a file to a workspace. + * + * Authorization runs fully (rate limit → workspace write access) before the + * multipart body is buffered: the workspace is a contract-validated query param, + * so an unauthorized caller never streams a 100 MB body into memory. + */ +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 parsed = await parseRequest( + v2UploadFileContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + let formData: FormData + try { + formData = await readFormDataWithLimit(request, { + maxBytes: MAX_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES, + label: 'workspace file 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 buffer = await readFileToBufferWithLimit(file, { + maxBytes: MAX_FILE_SIZE, + label: 'workspace upload file', + }) + + const userFile = await uploadWorkspaceFile( + workspaceId, + userId, + buffer, + file.name, + file.type || 'application/octet-stream' + ) + + logger.info(`Uploaded file: ${file.name} to workspace ${workspaceId}`) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.FILE_UPLOADED, + resourceType: AuditResourceType.FILE, + resourceId: userFile.id, + resourceName: file.name, + description: `Uploaded file "${file.name}" via API`, + metadata: { fileSize: file.size, fileType: file.type || 'application/octet-stream' }, + request, + }) + + const fileRecord = await getWorkspaceFile(workspaceId, userFile.id) + const uploadedAt = + fileRecord?.uploadedAt instanceof Date + ? fileRecord.uploadedAt.toISOString() + : fileRecord?.uploadedAt + ? String(fileRecord.uploadedAt) + : new Date().toISOString() + + const responseFile: V2File = { + id: userFile.id, + name: userFile.name, + size: userFile.size, + type: userFile.type, + key: userFile.key, + uploadedBy: userId, + uploadedAt, + } + + return v2Data(responseFile, { rateLimit, status: 201 }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return v2Error('PAYLOAD_TOO_LARGE', error.message) + } + + const message = getErrorMessage(error, 'Failed to upload file') + if (error instanceof FileConflictError || message.includes('already exists')) { + return v2Error('CONFLICT', message) + } + if (message.includes('Storage limit') || message.includes('storage limit')) { + return v2Error('PAYLOAD_TOO_LARGE', 'Storage limit exceeded') + } + + logger.error('Error uploading file', { error: message }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) 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..235c80707eb --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts @@ -0,0 +1,209 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +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 { deleteDocument } from '@/lib/knowledge/documents/service' +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 { v2Data, v2Error, 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 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 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') + + await deleteDocument(documentId, requestId) + + recordAudit({ + workspaceId: parsed.data.query.workspaceId, + actorId: userId, + action: AuditAction.DOCUMENT_DELETED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: documentId, + resourceName: doc.filename, + description: `Deleted document "${doc.filename}" from knowledge base via API`, + metadata: { knowledgeBaseId }, + request, + }) + + 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..9f2c7b5367a --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -0,0 +1,306 @@ +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 { + type V2KnowledgeDocumentSummary, + v2ListKnowledgeDocumentsContract, + v2UploadKnowledgeDocumentContract, +} from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' +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 { + createSingleDocument, + type DocumentData, + getDocuments, + processDocumentsWithQueue, +} from '@/lib/knowledge/documents/service' +import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' +import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' +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 { + decodeCursor, + encodeCursor, + v2CursorList, + v2Data, + v2Error, + 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 = 100 * 1024 * 1024 +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 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 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 + + // Fast usage gate before the storage write + indexing (the async backstop + // in processDocumentAsync still covers non-HTTP paths). + const usage = await checkActorUsageLimits(userId, workspaceId) + 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 newDocument = await createSingleDocument( + { + filename: file.name, + fileUrl: uploadedFile.url, + fileSize: file.size, + mimeType: contentType, + }, + knowledgeBaseId, + requestId, + userId + ) + + const documentData: DocumentData = { + documentId: newDocument.id, + filename: file.name, + fileUrl: uploadedFile.url, + fileSize: file.size, + mimeType: contentType, + } + + processDocumentsWithQueue([documentData], knowledgeBaseId, {}, requestId).catch(() => { + // Processing errors are logged internally by the queue. + }) + + 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, + }) + + 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) + } + + if (error instanceof Error) { + if ( + error.message.includes('Storage limit exceeded') || + error.message.includes('storage limit') + ) { + return v2Error('PAYLOAD_TOO_LARGE', 'Storage limit exceeded') + } + if (error.message.includes('already exists')) { + return v2Error('CONFLICT', 'Resource already exists') + } + } + + 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]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/route.ts new file mode 100644 index 00000000000..79bb1b4b86c --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/route.ts @@ -0,0 +1,193 @@ +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 { + v2DeleteKnowledgeBaseContract, + v2GetKnowledgeBaseContract, + v2UpdateKnowledgeBaseContract, +} from '@/lib/api/contracts/v2/knowledge' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { deleteKnowledgeBase, updateKnowledgeBase } from '@/lib/knowledge/service' +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 { v2Data, v2Error, 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 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 + + return v2Data({ knowledgeBase: formatKnowledgeBase(result.kb) }, { 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 parsed = await parseRequest(v2UpdateKnowledgeBaseContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, name, description, chunkingConfig } = parsed.data.body + + const result = await resolveKnowledgeBaseScoped(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({ + 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) }, + request, + }) + + return v2Data({ knowledgeBase: formatKnowledgeBase(updatedKb) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + if (error instanceof Error) { + if (error.message.includes('does not have permission')) { + return v2Error('FORBIDDEN', 'Access denied') + } + if (error.message.includes('already exists')) { + return v2Error('CONFLICT', 'Resource already exists') + } + } + + 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 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 + + 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`, + request, + }) + + 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/route.ts b/apps/sim/app/api/v2/knowledge/route.ts new file mode 100644 index 00000000000..d1fb7d5b10d --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -0,0 +1,140 @@ +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 { + v2CreateKnowledgeBaseContract, + v2ListKnowledgeBasesContract, +} from '@/lib/api/contracts/v2/knowledge' +import { isZodError, parseRequest } from '@/lib/api/server' +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 } from '@/lib/knowledge/service' +import { formatKnowledgeBase } from '@/app/api/v1/knowledge/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + v2CursorList, + v2Data, + v2Error, + 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 parsed = await parseRequest( + v2ListKnowledgeBasesContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const knowledgeBases = await getKnowledgeBases(userId, workspaceId) + const items = knowledgeBases.map(formatKnowledgeBase) + + // `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 parsed = await parseRequest( + v2CreateKnowledgeBaseContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, name, description, chunkingConfig } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const kb = await createKnowledgeBase( + { + name, + description, + workspaceId, + userId, + embeddingModel: getConfiguredEmbeddingModel(), + embeddingDimension: EMBEDDING_DIMENSIONS, + chunkingConfig: chunkingConfig ?? { maxSize: 1024, minSize: 100, overlap: 200 }, + }, + requestId + ) + + recordAudit({ + 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 }, + request, + }) + + return v2Data({ knowledgeBase: formatKnowledgeBase(kb) }, { rateLimit, status: 201 }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + if (error instanceof Error) { + if (error.message.includes('does not have permission')) { + return v2Error('FORBIDDEN', 'Access denied') + } + if ( + error.message.includes('Storage limit exceeded') || + error.message.includes('storage limit') + ) { + return v2Error('PAYLOAD_TOO_LARGE', 'Storage limit exceeded') + } + if (error.message.includes('already exists')) { + return v2Error('CONFLICT', 'Resource already exists') + } + } + + 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..8f432bf467e --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -0,0 +1,299 @@ +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 { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' +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 { + 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 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 and frozen status before spending. Tag-only search is free, so skip it. + if (query && query.trim().length > 0) { + const usage = await checkActorUsageLimits(userId, workspaceId) + 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, + workspaceId, + embeddingModel: queryEmbeddingModel, + query: query!, + isBYOK: queryEmbeddingIsBYOK, + sourceReference: `v2-kb-search:${requestId}`, + }) + } + + 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/response.ts b/apps/sim/app/api/v2/lib/response.ts new file mode 100644 index 00000000000..e6c5e3dc5d2 --- /dev/null +++ b/apps/sim/app/api/v2/lib/response.ts @@ -0,0 +1,144 @@ +import { NextResponse } from 'next/server' +import type { ZodError } from 'zod' +import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server' +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' + | 'INTERNAL_ERROR' + +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, + INTERNAL_ERROR: 500, +} + +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 { ...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: 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 + } +} 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..698e59f10ed --- /dev/null +++ b/apps/sim/app/api/v2/logs/[id]/route.ts @@ -0,0 +1,109 @@ +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 { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +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 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, + }) + .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 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, + folderId: log.workflowFolderId, + userId: log.workflowUserId, + workspaceId: log.workflowWorkspaceId, + createdAt: log.workflowCreatedAt ? log.workflowCreatedAt.toISOString() : null, + updatedAt: log.workflowUpdatedAt ? log.workflowUpdatedAt.toISOString() : null, + deleted: !log.workflowName, + }, + 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..da936577def --- /dev/null +++ b/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts @@ -0,0 +1,74 @@ +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 { 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 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..a4cc3372d37 --- /dev/null +++ b/apps/sim/app/api/v2/logs/route.ts @@ -0,0 +1,168 @@ +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, 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 { 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 { + 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 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 filters = { + workspaceId: params.workspaceId, + workflowIds: params.workflowIds?.split(',').filter(Boolean), + folderIds: params.folderIds?.split(',').filter(Boolean), + 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 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, + }) + .from(workflowExecutionLogs) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .where(conditions) + .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, + } + } + 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/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts new file mode 100644 index 00000000000..48b6726eb35 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts @@ -0,0 +1,269 @@ +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, + renameColumn, + updateColumnConstraints, + updateColumnType, +} from '@/lib/table' +import { checkAccess, normalizeColumn } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { v2TableAccessError } 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 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) + + if (error instanceof Error) { + if (error.message.includes('already exists') || error.message.includes('maximum column')) { + return v2Error('BAD_REQUEST', error.message) + } + if (error.message === 'Table not found') { + return v2Error('NOT_FOUND', error.message) + } + } + + 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 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 { updates } = validated + let updatedTable = null + + if (updates.name) { + updatedTable = await renameColumn( + { tableId, oldName: validated.columnName, newName: updates.name }, + requestId + ) + } + + if (updates.type) { + updatedTable = await updateColumnType( + { tableId, columnName: updates.name ?? validated.columnName, newType: updates.type }, + requestId + ) + } + + if (updates.required !== undefined || updates.unique !== undefined) { + updatedTable = await updateColumnConstraints( + { + tableId, + columnName: updates.name ?? validated.columnName, + ...(updates.required !== undefined ? { required: updates.required } : {}), + ...(updates.unique !== undefined ? { unique: updates.unique } : {}), + }, + requestId + ) + } + + if (!updatedTable) { + return v2Error('BAD_REQUEST', 'No updates specified') + } + + 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 v2Data({ columns: updatedTable.schema.columns.map(normalizeColumn) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + if (error instanceof Error) { + const msg = error.message + if (msg.includes('not found') || msg.includes('Table not found')) { + return v2Error('NOT_FOUND', msg) + } + 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') + ) { + return v2Error('BAD_REQUEST', msg) + } + } + + 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 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) + + if (error instanceof Error) { + if (error.message.includes('not found') || error.message === 'Table not found') { + return v2Error('NOT_FOUND', error.message) + } + if (error.message.includes('Cannot delete') || error.message.includes('last column')) { + return v2Error('BAD_REQUEST', error.message) + } + } + + 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]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts new file mode 100644 index 00000000000..202ad6a7900 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -0,0 +1,114 @@ +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 { v2DeleteTableContract, v2GetTableContract } 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 { deleteTable } from '@/lib/table' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiTable, v2TableAccessError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableDetailAPI') + +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 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') + } + + return v2Data({ table: toApiTable(result.table) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error getting table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/tables/[tableId] — Archive a table. */ +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 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') + } + + 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, + }) + + return v2Data({ id: tableId }, { rateLimit }) + } catch (error) { + 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]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts new file mode 100644 index 00000000000..59861c6c0ea --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts @@ -0,0 +1,226 @@ +import { db } from '@sim/db' +import { userTableRows } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } 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, buildNameById, rowDataNameToId, updateRow } from '@/lib/table' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiRow, v2TableAccessError } 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 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, + position: userTableRows.position, + 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 nameById = buildNameById(result.table.schema as TableSchema) + return v2Data( + { + row: toApiRow( + { + id: row.id, + data: row.data as RowData, + position: row.position, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }, + nameById + ), + }, + { 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 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 nameById = buildNameById(table.schema as TableSchema) + 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, nameById) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + const errorMessage = toError(error).message + if (errorMessage === 'Row not found') return v2Error('NOT_FOUND', errorMessage) + + 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 v2Error('BAD_REQUEST', errorMessage) + } + + 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 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 [deletedRow] = await db + .delete(userTableRows) + .where( + and( + eq(userTableRows.id, rowId), + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId) + ) + ) + .returning({ id: userTableRows.id }) + + if (!deletedRow) return v2Error('NOT_FOUND', 'Row not found') + + // v2 mirrors the bulk delete shape: always returns `deletedRowIds`. + return v2Data({ deletedCount: 1, deletedRowIds: [deletedRow.id] }, { rateLimit }) + } catch (error) { + 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/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts new file mode 100644 index 00000000000..60957cf8aa9 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts @@ -0,0 +1,406 @@ +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 { Filter, RowData, TableSchema } from '@/lib/table' +import { + batchInsertRows, + buildIdByName, + buildNameById, + deleteRowsByFilter, + deleteRowsByIds, + filterNamesToIds, + insertRow, + rowDataNameToId, + sortNamesToIds, + updateRowsByFilter, + validateBatchRows, + validateRowData, + validateRowSize, +} from '@/lib/table' +import { queryRows } from '@/lib/table/rows/service' +import { TableQueryValidationError } from '@/lib/table/sql' +import { checkAccess } from '@/app/api/table/utils' +import { + checkRateLimit, + type RateLimitResult, + resolveWorkspaceScope, +} from '@/app/api/v1/middleware' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { + toApiRow, + 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 nameById = buildNameById(table.schema as TableSchema) + 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, nameById)), + 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 — Query rows with filtering, sorting, offset pagination. */ +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 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') + } + + // Translate name-keyed filter/sort fields → column ids; translate rows back. + const idByName = buildIdByName(table.schema as TableSchema) + const nameById = buildNameById(table.schema as TableSchema) + const filter = validated.filter + ? filterNamesToIds(validated.filter as Filter, idByName) + : undefined + const sort = validated.sort ? sortNamesToIds(validated.sort, idByName) : undefined + + // 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, + { + filter, + sort, + 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, nameById)), + 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 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 nameById = buildNameById(table.schema as TableSchema) + 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, nameById) }, { 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 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 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: filterNamesToIds(validated.filter as Filter, idByName), + 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 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 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( + { tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId }, + requestId + ) + + return v2Data( + { + deletedCount: result.deletedCount, + deletedRowIds: result.deletedRowIds, + requestedCount: result.requestedCount, + missingRowIds: result.missingRowIds, + }, + { rateLimit } + ) + } + + const idByName = buildIdByName(table.schema as TableSchema) + const result = await deleteRowsByFilter( + table, + { filter: filterNamesToIds(validated.filter as Filter, idByName), 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..0831e5f702b --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts @@ -0,0 +1,98 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } 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, buildNameById, rowDataNameToId, upsertRow } from '@/lib/table' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { + 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 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 nameById = buildNameById(table.schema as TableSchema) + const upsertResult = await upsertRow( + { + tableId, + workspaceId: validated.workspaceId, + data: rowDataNameToId(validated.data as RowData, idByName), + userId, + conflictTarget: validated.conflictTarget, + }, + table, + requestId + ) + + // v2 includes `position` in the row object (via toApiRow) — v1 dropped it here. + return v2Data( + { row: toApiRow(upsertResult.row, nameById), operation: upsertResult.operation }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + 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 v2Error('BAD_REQUEST', errorMessage) + } + + 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/route.ts b/apps/sim/app/api/v2/tables/route.ts new file mode 100644 index 00000000000..27fe7418d95 --- /dev/null +++ b/apps/sim/app/api/v2/tables/route.ts @@ -0,0 +1,140 @@ +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 { 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 { createTable, getWorkspaceTableLimits, listTables, type TableSchema } from '@/lib/table' +import { normalizeColumn } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + v2CursorList, + 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 + +/** 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, 'tables') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2ListTablesContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const tables = await listTables(workspaceId) + const items = tables.map(toApiTable) + + // `listTables` returns the full bounded workspace set → single page. + return v2CursorList(items, null, { 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) + + const userId = rateLimit.userId! + 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 table = await createTable( + { + name: params.name, + description: params.description, + schema: normalizedSchema, + workspaceId: params.workspaceId, + userId, + maxTables: planLimits.maxTables, + }, + 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) }, { rateLimit, status: 201 }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + if (error instanceof Error) { + if (error.message.includes('maximum table limit')) { + return v2Error('FORBIDDEN', error.message) + } + if ( + error.message.includes('Invalid table name') || + error.message.includes('Invalid schema') || + error.message.includes('already exists') + ) { + return v2Error('BAD_REQUEST', error.message) + } + } + + 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..63bd09f8e89 --- /dev/null +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -0,0 +1,103 @@ +import type { NextResponse } from 'next/server' +import type { RowData, TableDefinition, TableSchema } from '@/lib/table' +import { rowDataIdToName } from '@/lib/table' +import { normalizeColumn, rootErrorMessage, rowWriteErrorResponse } from '@/app/api/table/utils' +import { v2Error } 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) +} + +/** + * 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) { + 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, + createdAt: toIso(table.createdAt), + updatedAt: toIso(table.updatedAt), + } +} + +/** + * Row fields the public API exposes. `data` is stored id-keyed; {@link toApiRow} + * translates it to column names. + */ +interface ApiRowInput { + id: string + data: RowData + position: number + createdAt: Date | string + updatedAt: Date | string +} + +/** + * Normalized public row shape. Callers pass the table's id→name map so `data` is + * keyed by column name (the public contract). `position` is always included — + * every v2 row endpoint, including upsert, exposes it. + */ +export function toApiRow(row: ApiRowInput, nameById: Map) { + return { + id: row.id, + data: rowDataIdToName(row.data, nameById), + position: row.position, + createdAt: toIso(row.createdAt), + updatedAt: toIso(row.updatedAt), + } +} + +/** + * 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 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/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts new file mode 100644 index 00000000000..87c46b2cd75 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts @@ -0,0 +1,169 @@ +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 { 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 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 { workflow, workspaceId } = target + + await assertWorkflowMutable(id) + + logger.info(`[${requestId}] Deploying workflow ${id} via v2 API`, { userId }) + + const result = await performFullDeploy({ + workflowId: id, + userId, + workflowName: workflow.name || undefined, + versionName: body.data.name, + versionDescription: body.data.description ?? undefined, + requestId, + request, + }) + + 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 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]/rollback/route.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts new file mode 100644 index 00000000000..634cf9957cf --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts @@ -0,0 +1,122 @@ +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 { 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 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, + workflow: workflow as Record, + requestId, + request, + }) + + 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.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts new file mode 100644 index 00000000000..a059d669648 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -0,0 +1,81 @@ +import { db } from '@sim/db' +import { workflowBlocks } from '@sim/db/schema' +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 { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2WorkflowDetail, v2GetWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowDetailAPI') + +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, 'workflow-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + 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 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, + folderId: 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') + } + } +) 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..a35f045bda7 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -0,0 +1,142 @@ +import { db } from '@sim/db' +import { workflow } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, asc, eq, gt, isNull, or } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2WorkflowListItem, v2ListWorkflowsContract } from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** Keyset cursor for the `(sortOrder, createdAt, id)` ordering. */ +interface WorkflowListCursor { + sortOrder: number + createdAt: string + id: string +} + +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 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 conditions = [eq(workflow.workspaceId, params.workspaceId), isNull(workflow.archivedAt)] + + if (params.folderId) { + conditions.push(eq(workflow.folderId, params.folderId)) + } + + if (params.deployedOnly) { + conditions.push(eq(workflow.isDeployed, true)) + } + + if (params.cursor) { + const cursorData = decodeCursor(params.cursor) + if (cursorData) { + const cursorCondition = or( + gt(workflow.sortOrder, cursorData.sortOrder), + and( + eq(workflow.sortOrder, cursorData.sortOrder), + gt(workflow.createdAt, new Date(cursorData.createdAt)) + ), + and( + eq(workflow.sortOrder, cursorData.sortOrder), + eq(workflow.createdAt, new Date(cursorData.createdAt)), + gt(workflow.id, cursorData.id) + ) + ) + if (cursorCondition) { + conditions.push(cursorCondition) + } + } + } + + 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(asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)) + .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 last = data[data.length - 1] + nextCursor = encodeCursor({ + sortOrder: last.sortOrder, + createdAt: last.createdAt.toISOString(), + id: last.id, + }) + } + + const formatted: V2WorkflowListItem[] = data.map((w) => ({ + id: w.id, + name: w.name, + description: w.description, + folderId: 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') + } +}) diff --git a/apps/sim/lib/api/contracts/v1/admin/organizations.ts b/apps/sim/lib/api/contracts/v1/admin/organizations.ts index 1281b5e649d..f64fd8da4b4 100644 --- a/apps/sim/lib/api/contracts/v1/admin/organizations.ts +++ b/apps/sim/lib/api/contracts/v1/admin/organizations.ts @@ -142,7 +142,8 @@ const adminV1RemoveOrganizationMemberResultSchema = z.object({ memberId: z.string(), userId: z.string(), billingActions: z.object({ - usageCaptured: z.boolean(), + /** Dollar amount of departed-member usage captured (0 when none). */ + usageCaptured: z.number(), proRestored: z.boolean(), usageRestored: z.boolean(), skipBillingLogic: z.boolean(), @@ -159,8 +160,10 @@ const adminV1TransferOwnershipResultSchema = z.object({ currentOwnerUserId: z.string(), newOwnerUserId: z.string(), workspacesReassigned: z.number(), - billedAccountReassigned: z.boolean(), - overageMigrated: z.boolean(), + /** Count of workspaces whose billed account was reassigned to the new owner. */ + billedAccountReassigned: z.number(), + /** Decimal-string dollar amount of overage migrated to the new owner ('0' when none). */ + overageMigrated: z.string(), billingBlockInherited: z.boolean(), }) diff --git a/apps/sim/lib/api/contracts/v1/audit-logs.ts b/apps/sim/lib/api/contracts/v1/audit-logs.ts index f82b86e4b6d..4ce86e22e9b 100644 --- a/apps/sim/lib/api/contracts/v1/audit-logs.ts +++ b/apps/sim/lib/api/contracts/v1/audit-logs.ts @@ -1,5 +1,11 @@ import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts/types' +import { + adminV1ListResponseSchema, + adminV1PaginationQuerySchema, + adminV1SingleResponseSchema, +} from '@/lib/api/contracts/v1/admin/shared' +import { v1UserLimitsSchema } from '@/lib/api/contracts/v1/shared' const isoDateString = z.string().refine((value) => !Number.isNaN(Date.parse(value)), { error: 'Invalid date format. Use ISO 8601.', @@ -43,25 +49,51 @@ export const v1AdminAuditLogsQuerySchema = z.object({ actorEmail: optionalQueryString, startDate: z.preprocess((value) => (value === '' ? undefined : value), isoDateString.optional()), endDate: z.preprocess((value) => (value === '' ? undefined : value), isoDateString.optional()), + ...adminV1PaginationQuerySchema.shape, }) /** - * Generic wrapper used by v1 admin audit-log responses. The `data` and - * `limits` halves are intentionally `z.unknown()` because this proxy returns - * provider-shaped payloads that vary per route family; tightening here would - * require a discriminated union per route, which is tracked as a follow-up. - * - * boundary-policy: this is the "validates nothing" alias form that the audit - * script's `untyped-response` regex doesn't currently catch. Treat any new - * wrapper of this shape the same way and either annotate at the contract use - * site with `// untyped-response: ` or replace with a concrete schema. + * Public enterprise audit-log entry. Mirrors `formatAuditLogEntry` in + * `app/api/v1/audit-logs/format.ts`; `ipAddress`/`userAgent` are intentionally + * excluded for privacy. `metadata` is genuinely arbitrary per-action JSON. */ -const apiResponseWithLimitsSchema = z - .object({ - data: z.unknown(), - limits: z.unknown().optional(), - }) - .passthrough() +const v1AuditLogEntrySchema = z.object({ + id: z.string(), + workspaceId: z.string().nullable(), + actorId: z.string().nullable(), + actorName: z.string().nullable(), + actorEmail: z.string().nullable(), + action: z.string(), + resourceType: z.string(), + resourceId: z.string().nullable(), + resourceName: z.string().nullable(), + description: z.string().nullable(), + metadata: z.unknown(), + createdAt: z.string(), +}) + +/** + * Admin audit-log entry. Mirrors `toAdminAuditLog` in `app/api/v1/admin/types.ts`, + * which additionally exposes `ipAddress`/`userAgent`. + */ +const adminV1AuditLogEntrySchema = v1AuditLogEntrySchema.extend({ + ipAddress: z.string().nullable(), + userAgent: z.string().nullable(), +}) + +const v1ListAuditLogsResponseSchema = z.object({ + data: z.array(v1AuditLogEntrySchema), + nextCursor: z.string().optional(), + limits: v1UserLimitsSchema, +}) + +const v1GetAuditLogResponseSchema = z.object({ + data: v1AuditLogEntrySchema, + limits: v1UserLimitsSchema, +}) + +export type V1AuditLogEntry = z.output +export type AdminV1AuditLogEntry = z.output export const v1ListAuditLogsContract = defineRouteContract({ method: 'GET', @@ -69,7 +101,7 @@ export const v1ListAuditLogsContract = defineRouteContract({ query: v1ListAuditLogsQuerySchema, response: { mode: 'json', - schema: apiResponseWithLimitsSchema, + schema: v1ListAuditLogsResponseSchema, }, }) @@ -79,7 +111,7 @@ export const v1GetAuditLogContract = defineRouteContract({ params: v1AuditLogParamsSchema, response: { mode: 'json', - schema: apiResponseWithLimitsSchema, + schema: v1GetAuditLogResponseSchema, }, }) @@ -89,7 +121,7 @@ export const v1AdminListAuditLogsContract = defineRouteContract({ query: v1AdminAuditLogsQuerySchema, response: { mode: 'json', - schema: apiResponseWithLimitsSchema, + schema: adminV1ListResponseSchema(adminV1AuditLogEntrySchema), }, }) @@ -99,6 +131,6 @@ export const v1AdminGetAuditLogContract = defineRouteContract({ params: v1AuditLogParamsSchema, response: { mode: 'json', - schema: apiResponseWithLimitsSchema, + schema: adminV1SingleResponseSchema(adminV1AuditLogEntrySchema), }, }) diff --git a/apps/sim/lib/api/contracts/v1/shared.ts b/apps/sim/lib/api/contracts/v1/shared.ts new file mode 100644 index 00000000000..9502e57ee5f --- /dev/null +++ b/apps/sim/lib/api/contracts/v1/shared.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' + +/** + * Rate-limit / usage envelope injected into every Family-A v1 response by + * `createApiResponse` (see `app/api/v1/logs/meta.ts`). Mirrors the `UserLimits` + * interface in that file. Shared here so logs, audit-logs, and workflows + * contracts describe `limits` identically instead of each redefining it. + */ +export const v1UserLimitsSchema = z.object({ + workflowExecutionRateLimit: z.object({ + sync: z.object({ + requestsPerMinute: z.number(), + maxBurst: z.number(), + remaining: z.number(), + resetAt: z.string(), + }), + async: z.object({ + requestsPerMinute: z.number(), + maxBurst: z.number(), + remaining: z.number(), + resetAt: z.string(), + }), + }), + usage: z.object({ + currentPeriodCost: z.number(), + limit: z.number(), + plan: z.string(), + isExceeded: z.boolean(), + }), +}) + +export type V1UserLimits = z.output + +/** + * Family-A envelope helper: `{ data, limits }`. Use for the `createApiResponse` + * detail/action surfaces (logs/[id], workflows deploy/rollback/undeploy). List + * endpoints that also return a `nextCursor` should compose the object directly + * (`{ data, nextCursor: z.string().optional(), limits: v1UserLimitsSchema }`). + */ +export const withV1Limits = (dataSchema: T) => + z.object({ + data: dataSchema, + limits: v1UserLimitsSchema, + }) diff --git a/apps/sim/lib/api/contracts/v2/audit-logs.ts b/apps/sim/lib/api/contracts/v2/audit-logs.ts new file mode 100644 index 00000000000..1084d9bbecb --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/audit-logs.ts @@ -0,0 +1,58 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v1AuditLogParamsSchema, + v1ListAuditLogsQuerySchema, +} from '@/lib/api/contracts/v1/audit-logs' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 audit-logs contracts. These are org-scoped enterprise endpoints. The + * request schemas are reused verbatim from v1 (the query/param shape is + * unchanged); only the response envelope is upgraded to the canonical v2 + * shapes. The v1 `limits` body is dropped — usage limits live on the dedicated + * usage endpoint, not inlined into every response. + */ + +/** + * Public enterprise audit-log entry. Mirrors `formatAuditLogEntry` in + * `app/api/v1/audit-logs/format.ts` and the v1 `v1AuditLogEntrySchema`; + * `ipAddress`/`userAgent` are intentionally excluded for privacy. `metadata` is + * genuinely arbitrary per-action JSON. + */ +export const v2AuditLogEntrySchema = z.object({ + id: z.string(), + workspaceId: z.string().nullable(), + actorId: z.string().nullable(), + actorName: z.string().nullable(), + actorEmail: z.string().nullable(), + action: z.string(), + resourceType: z.string(), + resourceId: z.string().nullable(), + resourceName: z.string().nullable(), + description: z.string().nullable(), + metadata: z.unknown(), + createdAt: z.string(), +}) + +export type V2AuditLogEntry = z.output + +export const v2ListAuditLogsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/audit-logs', + query: v1ListAuditLogsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2AuditLogEntrySchema), + }, +}) + +export const v2GetAuditLogContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/audit-logs/[id]', + params: v1AuditLogParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2AuditLogEntrySchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts new file mode 100644 index 00000000000..040ffa4dc80 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -0,0 +1,112 @@ +import { z } from 'zod' +import { workspaceFileIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 files contracts. v2 drops the v1 `{ success, data, limits }` envelope in + * favor of the canonical v2 shapes (`{ data }` / `{ data, nextCursor }`) and + * adds cursor pagination to the list. The workspace is always carried as a query + * param — including on upload — so the route can authorize before reading the + * multipart body. + */ + +/** A workspace file as exposed by the v2 surface. */ +export const v2FileSchema = z.object({ + id: z.string(), + name: z.string(), + size: z.number().nonnegative(), + type: z.string(), + key: z.string(), + uploadedBy: z.string(), + /** ISO-8601 timestamp. */ + uploadedAt: z.string(), +}) + +export type V2File = z.output + +/** Acknowledgement returned by a successful archive (soft delete). */ +export const v2DeleteFileResultSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) + +export type V2DeleteFileResult = z.output + +export const v2FileParamsSchema = z.object({ + fileId: workspaceFileIdSchema, +}) + +export type V2FileParams = z.output + +/** + * List query: workspace scope plus opaque keyset cursor pagination keyed on + * `(uploadedAt, id)`. `limit` clamps to `[1, 1000]` (default 100) to bound the + * response. The cursor is the base64-JSON codec shared across the v2 surface. + */ +export const v2ListFilesQuerySchema = z.object({ + workspaceId: workspaceIdSchema, + limit: z.coerce + .number() + .optional() + .default(100) + .transform((v) => Math.min(Math.max(1, Math.trunc(v)), 1000)), + cursor: z.string().min(1).optional(), +}) + +export type V2ListFilesQuery = z.output + +/** Upload carries the workspace as a query param so auth runs before buffering. */ +export const v2UploadFileQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) + +export type V2UploadFileQuery = z.output + +/** Download/delete both target a single file within a workspace-scoped query. */ +export const v2FileWorkspaceQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) + +export type V2FileWorkspaceQuery = z.output + +export const v2ListFilesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files', + query: v2ListFilesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2FileSchema), + }, +}) + +export const v2UploadFileContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files', + query: v2UploadFileQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FileSchema), + }, +}) + +export const v2DownloadFileContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files/[fileId]', + params: v2FileParamsSchema, + query: v2FileWorkspaceQuerySchema, + response: { + mode: 'binary', + }, +}) + +export const v2DeleteFileContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/files/[fileId]', + params: v2FileParamsSchema, + query: v2FileWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteFileResultSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts new file mode 100644 index 00000000000..06f4064d2fb --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -0,0 +1,270 @@ +import { z } from 'zod' +import { knowledgeBaseDataSchema } from '@/lib/api/contracts/knowledge/base' +import { documentDataSchema } from '@/lib/api/contracts/knowledge/documents' +import { + knowledgeBaseParamsSchema, + knowledgeDocumentParamsSchema, + nullableWireDateSchema, +} from '@/lib/api/contracts/knowledge/shared' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v1CreateKnowledgeBaseBodySchema, + v1KnowledgeSearchBodySchema, + v1KnowledgeWorkspaceQuerySchema, + v1ListKnowledgeBasesQuerySchema, + v1ListKnowledgeDocumentsQuerySchema, + v1UpdateKnowledgeBaseBodySchema, +} from '@/lib/api/contracts/v1/knowledge' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 knowledge contracts. + * + * Request shapes (params/query/body) are reused verbatim from the v1 public + * contract (`@/lib/api/contracts/v1/knowledge`) — the public request surface is + * unchanged. Only the response envelope is upgraded to the canonical v2 shapes + * (`{ data }` for single/mutation, `{ data, pagination }` for the offset-paginated + * document list), and the success `message` strings v1 inlined are dropped. + * + * The concrete `data` item schemas reuse the first-party knowledge data schemas + * as their source of truth: the knowledge-base item is a `.pick()` of + * {@link knowledgeBaseDataSchema} matching `formatKnowledgeBase`'s projection, + * and the document items reuse the core fields of {@link documentDataSchema}. The + * v2 (and v1-public) document projection renames `uploadedAt` to `createdAt` and + * omits `fileUrl`/tag slots, so that rename is layered on via `.extend()`. + */ + +/** + * Knowledge-base item — the exact subset `formatKnowledgeBase` projects from a + * {@link KnowledgeBaseWithCounts}. `userId`, `workspaceId`, and `deletedAt` are + * intentionally not exposed on the public surface. + */ +export const v2KnowledgeBaseSchema = knowledgeBaseDataSchema.pick({ + id: true, + name: true, + description: true, + tokenCount: true, + embeddingModel: true, + embeddingDimension: true, + chunkingConfig: true, + docCount: true, + connectorTypes: true, + createdAt: true, + updatedAt: true, +}) +export type V2KnowledgeBase = z.output + +/** `{ knowledgeBase }` payload for single-KB reads and mutations. */ +export const v2KnowledgeBaseDataSchema = z.object({ knowledgeBase: v2KnowledgeBaseSchema }) +export type V2KnowledgeBaseData = z.output + +/** Delete acknowledgement — the id of the resource that was deleted. */ +export const v2KnowledgeDeleteDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) +export type V2KnowledgeDeleteData = z.output + +/** + * Document core fields shared by the list item and the detail payload, reused + * from the first-party {@link documentDataSchema}. + */ +const v2KnowledgeDocumentCoreSchema = documentDataSchema.pick({ + id: true, + knowledgeBaseId: true, + filename: true, + fileSize: true, + mimeType: true, + processingStatus: true, + chunkCount: true, + tokenCount: true, + characterCount: true, + enabled: true, +}) + +/** + * Document list item / upload acknowledgement. `createdAt` is the public rename + * of the underlying `uploadedAt` column. + */ +export const v2KnowledgeDocumentSummarySchema = v2KnowledgeDocumentCoreSchema.extend({ + createdAt: nullableWireDateSchema, +}) +export type V2KnowledgeDocumentSummary = z.output + +/** + * Document detail — the summary plus processing state and connector provenance. + * Every field is always present (nullable), mirroring the v1 detail projection. + */ +export const v2KnowledgeDocumentSchema = v2KnowledgeDocumentSummarySchema.extend({ + processingError: z.string().nullable(), + processingStartedAt: nullableWireDateSchema, + processingCompletedAt: nullableWireDateSchema, + connectorId: z.string().nullable(), + connectorType: z.string().nullable(), + sourceUrl: z.string().nullable(), +}) +export type V2KnowledgeDocument = z.output + +/** `{ document }` payload for the upload acknowledgement (summary shape). */ +export const v2KnowledgeDocumentSummaryDataSchema = z.object({ + document: v2KnowledgeDocumentSummarySchema, +}) +export type V2KnowledgeDocumentSummaryData = z.output + +/** `{ document }` payload for the document detail read. */ +export const v2KnowledgeDocumentDataSchema = z.object({ document: v2KnowledgeDocumentSchema }) +export type V2KnowledgeDocumentData = z.output + +/** + * A single vector/tag search hit. `metadata` is the document's display-named tag + * map; values are user-defined and of mixed type (string/number/boolean/date), + * so they are carried as `unknown` and serialized as-is. + */ +export const v2KnowledgeSearchResultSchema = z.object({ + documentId: z.string(), + documentName: z.string().nullable(), + sourceUrl: z.string().nullable(), + content: z.string(), + chunkIndex: z.number(), + metadata: z.record(z.string(), z.unknown()), + similarity: z.number(), +}) +export type V2KnowledgeSearchResult = z.output + +/** Search response payload — mirrors the v1 `data` object. */ +export const v2KnowledgeSearchDataSchema = z.object({ + results: z.array(v2KnowledgeSearchResultSchema), + query: z.string(), + knowledgeBaseIds: z.array(z.string()), + topK: z.number(), + totalResults: z.number(), +}) +export type V2KnowledgeSearchData = z.output + +/** Upload carries the workspace as a query param so auth runs before the multipart body is buffered. */ +export const v2UploadKnowledgeDocumentQuerySchema = z.object({ workspaceId: workspaceIdSchema }) +export type V2UploadKnowledgeDocumentQuery = z.output + +/** + * KB list. `getKnowledgeBases` returns the full workspace set (a small, bounded + * per-workspace list), so today the cursor list is a single full page + * (`nextCursor` always `null`). The canonical cursor envelope keeps the v2 list + * surface uniform; real pagination can be added later behind the opaque cursor. + */ +export const v2ListKnowledgeBasesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge', + query: v1ListKnowledgeBasesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2KnowledgeBaseSchema), + }, +}) + +export const v2CreateKnowledgeBaseContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge', + body: v1CreateKnowledgeBaseBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeBaseDataSchema), + }, +}) + +export const v2GetKnowledgeBaseContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge/[id]', + params: knowledgeBaseParamsSchema, + query: v1KnowledgeWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeBaseDataSchema), + }, +}) + +export const v2UpdateKnowledgeBaseContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/knowledge/[id]', + params: knowledgeBaseParamsSchema, + body: v1UpdateKnowledgeBaseBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeBaseDataSchema), + }, +}) + +export const v2DeleteKnowledgeBaseContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/knowledge/[id]', + params: knowledgeBaseParamsSchema, + query: v1KnowledgeWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeDeleteDataSchema), + }, +}) + +export const v2SearchKnowledgeContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge/search', + body: v1KnowledgeSearchBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeSearchDataSchema), + }, +}) + +/** + * Document list query: the v1 search/filter/sort/limit shape with `offset` + * swapped for an opaque `cursor`. Total doc count is available as `docCount` on + * the knowledge base. + */ +export const v2ListKnowledgeDocumentsQuerySchema = v1ListKnowledgeDocumentsQuerySchema + .omit({ offset: true }) + .extend({ cursor: z.string().min(1).optional() }) +export type V2ListKnowledgeDocumentsQuery = z.output + +export const v2ListKnowledgeDocumentsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge/[id]/documents', + params: knowledgeBaseParamsSchema, + query: v2ListKnowledgeDocumentsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2KnowledgeDocumentSummarySchema), + }, +}) + +export const v2UploadKnowledgeDocumentContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge/[id]/documents', + params: knowledgeBaseParamsSchema, + query: v2UploadKnowledgeDocumentQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeDocumentSummaryDataSchema), + }, +}) + +export const v2GetKnowledgeDocumentContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge/[id]/documents/[documentId]', + params: knowledgeDocumentParamsSchema, + query: v1KnowledgeWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeDocumentDataSchema), + }, +}) + +export const v2DeleteKnowledgeDocumentContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/knowledge/[id]/documents/[documentId]', + params: knowledgeDocumentParamsSchema, + query: v1KnowledgeWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts new file mode 100644 index 00000000000..774aceb8794 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -0,0 +1,123 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v1ExecutionParamsSchema, + v1ListLogsQuerySchema, + v1LogParamsSchema, +} from '@/lib/api/contracts/v1/logs' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 logs contracts. The query schemas are reused verbatim from v1 (the request + * shape is unchanged); only the response envelope is upgraded to the canonical + * v2 shapes with concrete item schemas. + */ + +const v2LogCostSchema = z.object({ total: z.number() }).nullable() + +/** Execution `files` is a per-run jsonb array of attachment metadata. */ +const v2LogFilesSchema = z.array(z.unknown()).nullable() + +const v2LogWorkflowSummarySchema = z.object({ + id: z.string().nullable(), + name: z.string(), + description: z.string().nullable(), + deleted: z.boolean(), +}) + +export const v2LogListItemSchema = z.object({ + id: z.string(), + workflowId: z.string().nullable(), + executionId: z.string(), + deploymentVersionId: z.string().nullable(), + level: z.string(), + trigger: z.string(), + startedAt: z.string(), + endedAt: z.string().nullable(), + totalDurationMs: z.number().nullable(), + cost: v2LogCostSchema, + files: v2LogFilesSchema, + /** Present only when `details=full`. */ + workflow: v2LogWorkflowSummarySchema.optional(), + /** Present only when `details=full` and `includeFinalOutput=true`. */ + finalOutput: z.unknown().optional(), + /** Present only when `details=full` and `includeTraceSpans=true`. */ + traceSpans: z.unknown().optional(), +}) + +export type V2LogListItem = z.output + +export const v2LogDetailSchema = z.object({ + id: z.string(), + workflowId: z.string().nullable(), + executionId: z.string(), + level: z.string(), + trigger: z.string(), + startedAt: z.string(), + endedAt: z.string().nullable(), + totalDurationMs: z.number().nullable(), + files: v2LogFilesSchema, + workflow: z.object({ + id: z.string().nullable(), + name: z.string(), + description: z.string().nullable(), + folderId: z.string().nullable(), + userId: z.string().nullable(), + workspaceId: z.string().nullable(), + createdAt: z.string().nullable(), + updatedAt: z.string().nullable(), + deleted: z.boolean(), + }), + /** Materialized execution trace (block states, trace spans). */ + executionData: z.unknown(), + cost: v2LogCostSchema, + createdAt: z.string(), +}) + +export type V2LogDetail = z.output + +export const v2ExecutionSchema = z.object({ + executionId: z.string(), + workflowId: z.string().nullable(), + /** Workflow state snapshot at execution time. */ + workflowState: z.unknown(), + executionMetadata: z.object({ + trigger: z.string(), + startedAt: z.string(), + endedAt: z.string().nullable(), + totalDurationMs: z.number().nullable(), + cost: v2LogCostSchema, + }), +}) + +export type V2Execution = z.output + +export const v2ListLogsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/logs', + query: v1ListLogsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2LogListItemSchema), + }, +}) + +export const v2GetLogContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/logs/[id]', + params: v1LogParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2LogDetailSchema), + }, +}) + +export const v2GetExecutionContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/logs/executions/[executionId]', + params: v1ExecutionParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ExecutionSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts new file mode 100644 index 00000000000..d0579054727 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -0,0 +1,39 @@ +import { z } from 'zod' + +/** + * Shared building blocks for the v2 API contract surface. + * + * v2 standardizes on a single response family across every endpoint: + * - single resource: `{ data: T }` + * - list: `{ data: T[], nextCursor: string | null }` + * - error: `{ error: { code, message, details? } }` + * + * Every list uses the opaque-cursor envelope (Stripe/Slack-style): `limit` + + * `cursor` in, `{ data, nextCursor }` out. Cursors are opaque so the underlying + * scheme (keyset / offset / full-set) can change without a contract change. + * Total counts are not returned on lists — they're available on the parent + * resource where relevant (e.g. `rowCount` on a table, `docCount` on a KB). + * + * Rate-limit state is carried in `X-RateLimit-*` response headers (not the + * body). Usage limits are available from the dedicated usage endpoint rather + * than being inlined into every response. + */ + +/** Canonical v2 error envelope. */ +export const v2ErrorResponseSchema = z.object({ + error: z.object({ + code: z.string(), + message: z.string(), + details: z.unknown().optional(), + }), +}) + +/** `{ data: T }` */ +export const v2DataResponse = (dataSchema: T) => z.object({ data: dataSchema }) + +/** `{ data: T[], nextCursor: string | null }` — the v2 list envelope. */ +export const v2CursorListResponse = (itemSchema: T) => + z.object({ + data: z.array(itemSchema), + nextCursor: z.string().nullable(), + }) diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts new file mode 100644 index 00000000000..4fa64291fe6 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -0,0 +1,321 @@ +import { z } from 'zod' +import { + createTableColumnBodySchema, + deleteTableColumnBodySchema, + deleteTableRowsBodySchema, + tableColumnSchema, + tableIdParamsSchema, + tableRowParamsSchema, + updateRowsByFilterBodySchema, + updateTableColumnBodySchema, + updateTableRowBodySchema, + upsertTableRowBodySchema, +} from '@/lib/api/contracts/tables' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v1CreateTableBodySchema, + v1CreateTableRowsBodySchema, + v1ListTablesQuerySchema, + v1TableRowsQuerySchema, +} from '@/lib/api/contracts/v1/tables' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 tables contracts. + * + * Request shapes (params/query/body) are reused verbatim from the v1 contract + * and the first-party `/api/table` contract — the public table request surface + * is unchanged. Only the response envelope is upgraded to the canonical v2 + * shapes (`{ data }` for single/mutation, `{ data, pagination }` for the + * list/offset surfaces), and the outcome-dependent payloads are made consistent + * (see per-contract notes below). + * + * The `data` item schemas are concrete and describe exactly what the route's + * `toApiTable`/`toApiRow` serializers emit. The first-party + * `tableDefinitionSchema`/`tableRowSchema` are NOT reused here because they are + * opaque (`z.custom`) and their inferred types include fields the public wire + * never carries (`executions`, `workspaceId`, `Date` timestamps, …). Column + * shape is reused from the concrete first-party `tableColumnSchema`. + */ + +/** + * Public table shape emitted by `toApiTable` (timestamps ISO-serialized). + * Concrete so the v2 contract describes exactly what the wire carries. + */ +export const v2ApiTableSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + schema: z.object({ columns: z.array(tableColumnSchema) }), + rowCount: z.number(), + maxRows: z.number(), + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2ApiTable = z.output + +/** + * Public row shape emitted by `toApiRow`. `data` is keyed by column NAME (the + * id→name translation the route applies); cell values are user-defined, so the + * map is `Record`. Timestamps ISO. + */ +export const v2ApiRowSchema = z.object({ + id: z.string(), + data: z.record(z.string(), z.unknown()), + position: z.number(), + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2ApiRow = z.output + +/** A single table definition payload. */ +export const v2TableDataSchema = z.object({ table: v2ApiTableSchema }) +export type V2TableData = z.output + +/** Archive confirmation — the id of the table that was archived. */ +export const v2DeleteTableDataSchema = z.object({ id: z.string() }) +export type V2DeleteTableData = z.output + +/** The table's full column list after a column mutation. */ +export const v2TableColumnsDataSchema = z.object({ columns: z.array(tableColumnSchema) }) +export type V2TableColumnsData = z.output + +/** A single row payload. */ +export const v2TableRowDataSchema = z.object({ row: v2ApiRowSchema }) +export type V2TableRowData = z.output + +/** Batch-insert payload. */ +export const v2BatchInsertRowsDataSchema = z.object({ + rows: z.array(v2ApiRowSchema), + insertedCount: z.number(), +}) +export type V2BatchInsertRowsData = z.output + +/** + * Bulk update-by-filter payload. v2 always returns `updatedRowIds` (`[]` when + * nothing matched) — v1 dropped the field on the zero-match branch. + */ +export const v2UpdateRowsDataSchema = z.object({ + updatedCount: z.number(), + updatedRowIds: z.array(z.string()), +}) +export type V2UpdateRowsData = z.output + +/** + * Bulk delete payload — one consistent shape for both id-based and + * filter-based deletes. `requestedCount`/`missingRowIds` are populated for the + * id-based delete (which has a requested set) and omitted for the filter-based + * delete; v1 emitted two divergent shapes here. + */ +export const v2DeleteRowsDataSchema = z.object({ + deletedCount: z.number(), + deletedRowIds: z.array(z.string()), + requestedCount: z.number().optional(), + missingRowIds: z.array(z.string()).optional(), +}) +export type V2DeleteRowsData = z.output + +/** Single-row delete payload — mirrors the bulk shape's required fields. */ +export const v2DeleteRowDataSchema = z.object({ + deletedCount: z.number(), + deletedRowIds: z.array(z.string()), +}) +export type V2DeleteRowData = z.output + +/** Upsert payload — the row object includes `position` like every other row endpoint. */ +export const v2UpsertRowDataSchema = z.object({ + row: v2ApiRowSchema, + operation: z.enum(['insert', 'update']), +}) +export type V2UpsertRowData = z.output + +/** + * Table list. `listTables` returns every table in the workspace (a small, + * bounded per-workspace set), so today the cursor list is a single full page + * (`nextCursor` is always `null`). Using the canonical cursor envelope keeps the + * whole v2 list surface uniform, and real pagination can be added later behind + * the opaque cursor without an interface change. + */ +export const v2ListTablesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables', + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2ApiTableSchema), + }, +}) + +export const v2CreateTableContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables', + body: v1CreateTableBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableDataSchema), + }, +}) + +export const v2GetTableContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]', + params: tableIdParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableDataSchema), + }, +}) + +export const v2DeleteTableContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/[tableId]', + params: tableIdParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteTableDataSchema), + }, +}) + +export const v2AddTableColumnContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/columns', + params: tableIdParamsSchema, + body: createTableColumnBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableColumnsDataSchema), + }, +}) + +export const v2UpdateTableColumnContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/tables/[tableId]/columns', + params: tableIdParamsSchema, + body: updateTableColumnBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableColumnsDataSchema), + }, +}) + +export const v2DeleteTableColumnContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/[tableId]/columns', + params: tableIdParamsSchema, + body: deleteTableColumnBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableColumnsDataSchema), + }, +}) + +/** + * Row list query: the v1 filter/sort/limit request shape with `offset` swapped + * for an opaque `cursor` (cursor-uniform v2 pagination). The cursor encodes the + * underlying offset today; it can move to a keyset implementation later without + * an interface change. Total row count is available as `rowCount` on the table. + */ +export const v2TableRowsQuerySchema = v1TableRowsQuerySchema.omit({ offset: true }).extend({ + cursor: z.string().min(1).optional(), +}) +export type V2TableRowsQuery = z.output + +/** Cursor-paginated row list. */ +export const v2ListTableRowsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/rows', + params: tableIdParamsSchema, + query: v2TableRowsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2ApiRowSchema), + }, +}) + +/** + * Single contract for `POST /rows` — the body is the single|batch union so the + * route can dispatch in one `parseRequest`, and the response is the matching + * union (`{ data: { row } }` for a single insert, `{ data: { rows, + * insertedCount } }` for a batch). + */ +export const v2CreateTableRowsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/rows', + params: tableIdParamsSchema, + body: v1CreateTableRowsBodySchema, + response: { + mode: 'json', + schema: z.union([ + v2DataResponse(v2TableRowDataSchema), + v2DataResponse(v2BatchInsertRowsDataSchema), + ]), + }, +}) + +export const v2UpdateRowsByFilterContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/tables/[tableId]/rows', + params: tableIdParamsSchema, + body: updateRowsByFilterBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2UpdateRowsDataSchema), + }, +}) + +export const v2DeleteTableRowsContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/[tableId]/rows', + params: tableIdParamsSchema, + body: deleteTableRowsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteRowsDataSchema), + }, +}) + +export const v2GetTableRowContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/rows/[rowId]', + params: tableRowParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableRowDataSchema), + }, +}) + +export const v2UpdateTableRowContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/tables/[tableId]/rows/[rowId]', + params: tableRowParamsSchema, + body: updateTableRowBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableRowDataSchema), + }, +}) + +export const v2DeleteTableRowContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/[tableId]/rows/[rowId]', + params: tableRowParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteRowDataSchema), + }, +}) + +export const v2UpsertTableRowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/rows/upsert', + params: tableIdParamsSchema, + body: upsertTableRowBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2UpsertRowDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts new file mode 100644 index 00000000000..05ca4a36fe8 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -0,0 +1,112 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v1DeployWorkflowDataSchema, + v1ListWorkflowsQuerySchema, + v1RollbackWorkflowDataSchema, +} from '@/lib/api/contracts/v1/workflows' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { workflowIdParamsSchema } from '@/lib/api/contracts/workflows' + +/** + * v2 workflows contracts. Request shapes are reused verbatim from v1 (the list + * query and `[id]` param are unchanged); only the response envelope is upgraded + * to the canonical v2 shapes with concrete item/detail schemas. The + * deploy/rollback/undeploy data payloads reuse the already-concrete v1 schemas, + * re-wrapped in `v2DataResponse` (the v1 `limits` body field is dropped — v2 + * carries rate-limit state in headers and usage on a dedicated endpoint). + */ + +export const v2WorkflowListItemSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + folderId: z.string().nullable(), + workspaceId: z.string(), + isDeployed: z.boolean(), + deployedAt: z.string().nullable(), + runCount: z.number(), + lastRunAt: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}) + +export type V2WorkflowListItem = z.output + +/** A single trigger input field extracted from the workflow's input-definition block. */ +const v2WorkflowInputFieldSchema = z.object({ + name: z.string(), + type: z.string(), + description: z.string().optional(), +}) + +export const v2WorkflowDetailSchema = v2WorkflowListItemSchema.extend({ + /** + * 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/free-form. Kept as `unknown` to tolerate legacy/unstamped + * rows — tightening to a concrete object schema later is consumer-safe (the + * wire already carries the full object), so it stays additively evolvable. + */ + variables: z.record(z.string(), z.unknown()), + inputs: z.array(v2WorkflowInputFieldSchema), +}) + +export type V2WorkflowDetail = z.output + +/** + * Undeploy returns the deployment state without a version number. Derived from + * the exported v1 deploy data schema (its private base is not exported) so the + * shape stays in lockstep with v1. + */ +const v2UndeployWorkflowDataSchema = v1DeployWorkflowDataSchema.omit({ version: true }) + +export const v2ListWorkflowsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows', + query: v1ListWorkflowsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2WorkflowListItemSchema), + }, +}) + +export const v2GetWorkflowContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[id]', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowDetailSchema), + }, +}) + +export const v2DeployWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[id]/deploy', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v1DeployWorkflowDataSchema), + }, +}) + +export const v2UndeployWorkflowContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/workflows/[id]/deploy', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2UndeployWorkflowDataSchema), + }, +}) + +export const v2RollbackWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[id]/rollback', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v1RollbackWorkflowDataSchema), + }, +}) diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts index 30379de8031..4d352d041a5 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts @@ -40,6 +40,12 @@ export interface PerformDeleteWorkspaceFileItemsParams { userId: string fileIds?: string[] folderIds?: string[] + /** + * Optional originating request, forwarded to the audit log so the deletion + * entry captures client IP / user agent. Omitted by in-app callers that have + * no HTTP request in scope. + */ + request?: { headers: { get(name: string): string | null } } } export interface PerformDeleteWorkspaceFileItemsResult { @@ -137,7 +143,7 @@ export interface PerformRestoreWorkspaceFileFolderResult { export async function performDeleteWorkspaceFileItems( params: PerformDeleteWorkspaceFileItemsParams ): Promise { - const { workspaceId, userId, fileIds = [], folderIds = [] } = params + const { workspaceId, userId, fileIds = [], folderIds = [], request } = params if (fileIds.length === 0 && folderIds.length === 0) { return { @@ -172,6 +178,7 @@ export async function performDeleteWorkspaceFileItems( resourceType: AuditResourceType.FILE, description: `Deleted ${fileIds.length} file${fileIds.length === 1 ? '' : 's'}`, metadata: { fileIds }, + request, }) } @@ -190,6 +197,7 @@ export async function performDeleteWorkspaceFileItems( folders: deletedItems.folders, }, }, + request, }) } diff --git a/bun.lock b/bun.lock index 2b92202f9ae..a7d73045fb9 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "simstudio", @@ -2952,7 +2951,7 @@ "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], - "lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], + "lucide-react": ["lucide-react@1.18.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -4306,6 +4305,8 @@ "@shuding/opentype.js/fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], + "@sim/emcn/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], + "@sim/realtime/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], "@sim/runtime-secrets/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], @@ -4496,16 +4497,12 @@ "fumadocs-openapi/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - "fumadocs-openapi/lucide-react": ["lucide-react@1.18.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA=="], - "fumadocs-openapi/shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="], "fumadocs-openapi/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "fumadocs-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], - "fumadocs-ui/lucide-react": ["lucide-react@1.18.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA=="], - "fumadocs-ui/shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="], "fumadocs-ui/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], @@ -4658,6 +4655,8 @@ "sim/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], + "sim/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], + "sim/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], "simstudio/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 680dfa03bf1..74074d6c1c0 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 872, - zodRoutes: 872, + totalRoutes: 887, + zodRoutes: 887, nonZodRoutes: 0, } as const From 65dc7e8495ee0939d1acb6b37f12712aef9fae46 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 30 Jul 2026 11:17:33 -0700 Subject: [PATCH 02/24] feat(usage): accept X-API-Key on usage-logs list + export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/users/me/usage-logs and /export now use checkHybridAuth — the same auth /api/users/me/usage-limits already accepts — so external monitors can read summary.bySourceCredits (the source breakdown of usage-limits' aggregate currentPeriodCost) instead of estimating Copilot spend by subtraction. Workspace-scoped keys are pinned to their own workspace's slice of the ledger: the filter defaults to the key's workspace and an explicit mismatch 403s. Both endpoints documented in openapi-core.json. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- apps/docs/openapi-core.json | 285 ++++++++++++++++++ .../api/users/me/usage-logs/export/route.ts | 14 +- .../app/api/users/me/usage-logs/route.test.ts | 54 +++- apps/sim/app/api/users/me/usage-logs/route.ts | 19 +- .../sim/app/api/users/me/usage-logs/shared.ts | 31 ++ 5 files changed, 394 insertions(+), 9 deletions(-) diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json index 53a99c2e866..e5bfe75bf6d 100644 --- a/apps/docs/openapi-core.json +++ b/apps/docs/openapi-core.json @@ -1025,6 +1025,291 @@ }, "parameters": [] } + }, + "/api/users/me/usage-logs": { + "get": { + "operationId": "listUsageLogs", + "summary": "List Usage Logs", + "description": "The authenticated account's credit-consuming usage events with a per-source summary. Accepts a session or `X-API-Key` — the same key `GET /api/users/me/usage-limits` accepts; `summary.bySourceCredits` is the source breakdown of that endpoint's aggregate `currentPeriodCost`, suitable for monitoring e.g. Copilot consumption. Workspace-scoped keys read only their own workspace's slice of the ledger.", + "tags": ["Usage"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "source", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Restrict to one usage source (e.g. `workflow`, `copilot`). Omit for all sources." + }, + { + "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." + }, + { + "name": "includeCredits", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": true + }, + "description": "Set `false` to skip per-row credit apportionment when only the summary is needed." + } + ], + "responses": { + "200": { + "description": "A page of usage events with the per-source credit summary.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["success", "logs", "summary", "pagination"], + "properties": { + "success": { + "type": "boolean" + }, + "logs": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "createdAt", + "source", + "workflowName", + "creditCost", + "dollarCost" + ], + "properties": { + "id": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "source": { + "type": "string", + "description": "Usage source: `workflow`, `copilot`, and the other credit-consuming surfaces." + }, + "workflowName": { + "type": ["string", "null"], + "description": "Populated only when `source` is `workflow`." + }, + "creditCost": { + "type": "number", + "description": "Credit-denominated cost (1,000 credits = $5), apportioned so page rows sum exactly to the rounded page total." + }, + "dollarCost": { + "type": "number", + "description": "Raw dollar cost, so a 0 `creditCost` can be distinguished from a genuinely free event." + } + } + } + }, + "summary": { + "type": "object", + "required": ["totalCredits", "bySourceCredits"], + "properties": { + "totalCredits": { + "type": "number" + }, + "bySourceCredits": { + "type": "object", + "additionalProperties": { + "type": "number" + }, + "description": "Credits per usage source over the whole filter — the source-aware breakdown of `usage-limits`’ aggregate `currentPeriodCost`." + } + } + }, + "pagination": { + "type": "object", + "required": ["hasMore"], + "properties": { + "nextCursor": { + "type": "string" + }, + "hasMore": { + "type": "boolean" + } + } + } + } + }, + "example": { + "success": true, + "logs": [ + { + "id": "log_1", + "createdAt": "2026-07-29T18:04:11.000Z", + "source": "copilot", + "workflowName": null, + "creditCost": 12, + "dollarCost": 0.06 + } + ], + "summary": { + "totalCredits": 512, + "bySourceCredits": { + "workflow": 380, + "copilot": 120, + "knowledge-base": 12 + } + }, + "pagination": { + "hasMore": false + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/api/users/me/usage-logs/export": { + "get": { + "operationId": "exportUsageLogs", + "summary": "Export Usage Logs (CSV)", + "description": "Every usage event matching the filter as a CSV download (`Date`, `Type`, `Credits`) — the unpaginated form of `GET /api/users/me/usage-logs`, same auth and workspace-key scoping.", + "tags": ["Usage"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "source", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Restrict to one usage source (e.g. `workflow`, `copilot`). Omit for all sources." + }, + { + "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." + } + ], + "responses": { + "200": { + "description": "CSV attachment. `X-Export-Truncated: 1` signals the 50,000-row safety cap was hit.", + "content": { + "text/csv": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } } }, "components": { diff --git a/apps/sim/app/api/users/me/usage-logs/export/route.ts b/apps/sim/app/api/users/me/usage-logs/export/route.ts index d32e6d59837..052ed96de6c 100644 --- a/apps/sim/app/api/users/me/usage-logs/export/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/export/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { exportUsageLogsContract } from '@/lib/api/contracts/user' import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { checkHybridAuth } from '@/lib/auth/hybrid' import { getUsageCreditsByLogId, getUserUsageLogs, @@ -10,7 +10,10 @@ import { } from '@/lib/billing/core/usage-log' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { formatCsvValue, toCsvRow } from '@/lib/table/export-format' -import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' +import { + resolveDateRange, + resolveUsageLogsWorkspaceFilter, +} from '@/app/api/users/me/usage-logs/shared' import { USAGE_LOG_SOURCE_LABELS } from '@/app/api/users/me/usage-logs/source-labels' const logger = createLogger('UsageLogsExportAPI') @@ -33,7 +36,7 @@ const CSV_HEADER = toCsvRow(['Date', 'Type', 'Credits']) * (unlike, say, a workspace's full execution history). */ export const GET = withRouteHandler(async (request: NextRequest) => { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + const auth = await checkHybridAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -42,10 +45,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const { source, workspaceId, period, startDate, endDate } = parsed.data.query + const workspaceFilter = resolveUsageLogsWorkspaceFilter(auth, workspaceId) + if (!workspaceFilter.ok) return workspaceFilter.response + const dateRange = resolveDateRange(period, startDate, endDate) const filter = { source: source as UsageLogSource | undefined, - workspaceId, + workspaceId: workspaceFilter.workspaceId, startDate: dateRange.startDate, endDate: dateRange.endDate, } 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..256f73b9f48 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 @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { authMockFns, createMockRequest } from '@sim/testing' +import { authMockFns, createMockRequest, hybridAuthMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { apportionCredits } from '@/lib/billing/credits/conversion' @@ -46,6 +46,58 @@ describe('GET /api/users/me/usage-logs', () => { expect(response.status).toBe(401) }) + it('accepts a personal API key and reads that user’s ledger unscoped', async () => { + hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ + success: true, + userId: 'key-owner', + authType: 'api_key', + apiKeyType: 'personal', + }) + + const response = await GET(createMockRequest('GET')) + + expect(response.status).toBe(200) + expect(mockGetUserUsageLogs).toHaveBeenCalledWith( + 'key-owner', + expect.objectContaining({ workspaceId: undefined }) + ) + }) + + it('pins a workspace API key to its own workspace’s slice of the ledger', async () => { + hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ + success: true, + userId: 'key-owner', + workspaceId: 'ws-1', + authType: 'api_key', + apiKeyType: 'workspace', + }) + + const response = await GET(createMockRequest('GET')) + + expect(response.status).toBe(200) + expect(mockGetUserUsageLogs).toHaveBeenCalledWith( + 'key-owner', + expect.objectContaining({ workspaceId: 'ws-1' }) + ) + }) + + it('rejects a workspace API key asking for a different workspace', async () => { + hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ + success: true, + userId: 'key-owner', + workspaceId: 'ws-1', + authType: 'api_key', + apiKeyType: 'workspace', + }) + + const response = await GET( + createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?workspaceId=ws-2') + ) + + expect(response.status).toBe(403) + expect(mockGetUserUsageLogs).not.toHaveBeenCalled() + }) + it('converts dollar costs to credits in the logs and summary', async () => { const response = await GET(createMockRequest('GET')) const body = await response.json() 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..56d86cba761 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { getUsageLogsContract } from '@/lib/api/contracts/user' import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { checkHybridAuth } from '@/lib/auth/hybrid' import { getUsageCreditsByLogId, getUserUsageLogs, @@ -10,16 +10,24 @@ import { } from '@/lib/billing/core/usage-log' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' +import { + resolveDateRange, + resolveUsageLogsWorkspaceFilter, +} from '@/app/api/users/me/usage-logs/shared' 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. + * + * Accepts session auth AND `X-API-Key` (matching `/api/users/me/usage-limits`, + * whose aggregate `currentPeriodCost` this endpoint's `summary` breaks down by + * source) so external monitors can watch e.g. Copilot consumption. Workspace + * keys are pinned to their own workspace's slice of the ledger. */ export const GET = withRouteHandler(async (request: NextRequest) => { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + const auth = await checkHybridAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -29,11 +37,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { source, workspaceId, period, startDate, endDate, limit, cursor, includeCredits } = parsed.data.query + const workspaceFilter = resolveUsageLogsWorkspaceFilter(auth, workspaceId) + if (!workspaceFilter.ok) return workspaceFilter.response + const dateRange = resolveDateRange(period, startDate, endDate) const filter = { source: source as UsageLogSource | undefined, - workspaceId, + workspaceId: workspaceFilter.workspaceId, startDate: dateRange.startDate, endDate: dateRange.endDate, } diff --git a/apps/sim/app/api/users/me/usage-logs/shared.ts b/apps/sim/app/api/users/me/usage-logs/shared.ts index 1c143248edc..e751ec52a87 100644 --- a/apps/sim/app/api/users/me/usage-logs/shared.ts +++ b/apps/sim/app/api/users/me/usage-logs/shared.ts @@ -1,7 +1,38 @@ +import { NextResponse } from 'next/server' import type { UsageLogPeriod } from '@/lib/api/contracts/user' +import type { AuthResult } from '@/lib/auth/hybrid' const PERIOD_TO_DAYS: Record<'1d' | '7d' | '30d', number> = { '1d': 1, '7d': 7, '30d': 30 } +type WorkspaceFilterResult = + | { ok: true; workspaceId: string | undefined } + | { ok: false; response: NextResponse } + +/** + * Resolves the effective `workspaceId` ledger filter for the caller's + * credential. Sessions, internal JWTs, and personal API keys read the + * authenticated user's full ledger with whatever filter they asked for; a + * workspace-scoped API 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 resolveUsageLogsWorkspaceFilter( + auth: AuthResult, + requestedWorkspaceId: string | undefined +): WorkspaceFilterResult { + if (auth.apiKeyType !== 'workspace') return { ok: true, workspaceId: requestedWorkspaceId } + if (requestedWorkspaceId && requestedWorkspaceId !== auth.workspaceId) { + return { + ok: false, + response: NextResponse.json( + { error: 'API key is not authorized for this workspace' }, + { status: 403 } + ), + } + } + return { ok: true, workspaceId: auth.workspaceId } +} + interface ResolvedDateRange { startDate: Date | undefined endDate: Date From 3a3ceb5ced2ad9ac1eba0e920ebeaff1b5592cf6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 30 Jul 2026 11:52:34 -0700 Subject: [PATCH 03/24] feat(billing): dedicated v2 usage endpoints; keep internal usage routes session-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the earlier X-API-Key enablement on /api/users/me/usage-logs with a dedicated public surface, so the internal Billing-settings endpoints can evolve with the UI while external monitors get a stable versioned contract: - GET /api/v2/billing/usage — current-billing-period summary with bySourceCredits (the source breakdown external monitors need to watch e.g. Copilot consumption without estimating by subtraction), plus limitCredits and plan - GET /api/v2/billing/usage/logs — cursor-paged credit ledger in the v2 envelope - workspace-scoped keys are pinned to their own workspace's slice; personal keys read the account ledger The public wire is credits-only: usage-logs rows now carry a hasCost boolean instead of dollarCost (the Billing UI only needed the >0 signal), and the rateLimit block is removed from the usage-limits response and docs (deploy-modal tab relabeled accordingly). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- apps/docs/openapi-core.json | 416 ++++++------------ .../app/api/users/me/usage-limits/route.ts | 36 +- .../api/users/me/usage-logs/export/route.ts | 14 +- .../app/api/users/me/usage-logs/route.test.ts | 56 +-- apps/sim/app/api/users/me/usage-logs/route.ts | 22 +- .../sim/app/api/users/me/usage-logs/shared.ts | 31 -- apps/sim/app/api/v1/middleware.ts | 1 + .../api/v2/billing/usage/logs/route.test.ts | 131 ++++++ .../app/api/v2/billing/usage/logs/route.ts | 87 ++++ .../app/api/v2/billing/usage/route.test.ts | 140 ++++++ apps/sim/app/api/v2/billing/usage/route.ts | 87 ++++ apps/sim/app/api/v2/billing/utils.ts | 30 ++ .../credit-usage/credit-usage-view.tsx | 2 +- .../deploy-modal/components/api/api.tsx | 4 +- apps/sim/lib/api/contracts/user.ts | 12 +- apps/sim/lib/api/contracts/v2/billing.ts | 97 ++++ apps/sim/lib/billing/credits/conversion.ts | 16 +- scripts/check-api-validation-contracts.ts | 4 +- 18 files changed, 738 insertions(+), 448 deletions(-) create mode 100644 apps/sim/app/api/v2/billing/usage/logs/route.test.ts create mode 100644 apps/sim/app/api/v2/billing/usage/logs/route.ts create mode 100644 apps/sim/app/api/v2/billing/usage/route.test.ts create mode 100644 apps/sim/app/api/v2/billing/usage/route.ts create mode 100644 apps/sim/app/api/v2/billing/utils.ts create mode 100644 apps/sim/lib/api/contracts/v2/billing.ts diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json index e5bfe75bf6d..1e7e62a7a84 100644 --- a/apps/docs/openapi-core.json +++ b/apps/docs/openapi-core.json @@ -973,7 +973,7 @@ "get": { "operationId": "getUsageLimits", "summary": "Get Usage Limits", - "description": "Retrieve your current rate limits, usage spending, and storage consumption for the billing period.", + "description": "Retrieve your current usage spending and storage consumption for the billing period.", "tags": ["Usage"], "x-codeSamples": [ { @@ -985,7 +985,7 @@ ], "responses": { "200": { - "description": "Current rate limits, usage, and storage information.", + "description": "Current usage and storage information.", "content": { "application/json": { "schema": { @@ -993,18 +993,6 @@ }, "example": { "success": true, - "rateLimit": { - "sync": { - "limit": 100, - "remaining": 95, - "reset": "2026-01-15T11:00:00Z" - }, - "async": { - "limit": 50, - "remaining": 48, - "reset": "2026-01-15T11:00:00Z" - } - }, "usage": { "currentPeriodCost": 12.5, "limit": 100, @@ -1026,11 +1014,11 @@ "parameters": [] } }, - "/api/users/me/usage-logs": { + "/api/v2/billing/usage": { "get": { - "operationId": "listUsageLogs", - "summary": "List Usage Logs", - "description": "The authenticated account's credit-consuming usage events with a per-source summary. Accepts a session or `X-API-Key` — the same key `GET /api/users/me/usage-limits` accepts; `summary.bySourceCredits` is the source breakdown of that endpoint's aggregate `currentPeriodCost`, suitable for monitoring e.g. Copilot consumption. Workspace-scoped keys read only their own workspace's slice of the ledger.", + "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": [ { @@ -1038,15 +1026,6 @@ } ], "parameters": [ - { - "name": "source", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Restrict to one usage source (e.g. `workflow`, `copilot`). Omit for all sources." - }, { "name": "workspaceId", "in": "query", @@ -1055,121 +1034,41 @@ "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." - }, - { - "name": "includeCredits", - "in": "query", - "required": false, - "schema": { - "type": "boolean", - "default": true - }, - "description": "Set `false` to skip per-row credit apportionment when only the summary is needed." } ], "responses": { "200": { - "description": "A page of usage events with the per-source credit summary.", + "description": "The current billing period's usage summary.", "content": { "application/json": { "schema": { "type": "object", - "required": ["success", "logs", "summary", "pagination"], + "required": ["data"], "properties": { - "success": { - "type": "boolean" - }, - "logs": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "createdAt", - "source", - "workflowName", - "creditCost", - "dollarCost" - ], - "properties": { - "id": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "source": { - "type": "string", - "description": "Usage source: `workflow`, `copilot`, and the other credit-consuming surfaces." - }, - "workflowName": { - "type": ["string", "null"], - "description": "Populated only when `source` is `workflow`." - }, - "creditCost": { - "type": "number", - "description": "Credit-denominated cost (1,000 credits = $5), apportioned so page rows sum exactly to the rounded page total." - }, - "dollarCost": { - "type": "number", - "description": "Raw dollar cost, so a 0 `creditCost` can be distinguished from a genuinely free event." - } - } - } - }, - "summary": { + "data": { "type": "object", - "required": ["totalCredits", "bySourceCredits"], + "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" }, @@ -1178,65 +1077,57 @@ "additionalProperties": { "type": "number" }, - "description": "Credits per usage source over the whole filter — the source-aware breakdown of `usage-limits`’ aggregate `currentPeriodCost`." - } - } - }, - "pagination": { - "type": "object", - "required": ["hasMore"], - "properties": { - "nextCursor": { - "type": "string" + "description": "Credits consumed per usage source over the billing period." + }, + "limitCredits": { + "type": "number" }, - "hasMore": { - "type": "boolean" + "plan": { + "type": "string" } } } } }, "example": { - "success": true, - "logs": [ - { - "id": "log_1", - "createdAt": "2026-07-29T18:04:11.000Z", - "source": "copilot", - "workflowName": null, - "creditCost": 12, - "dollarCost": 0.06 - } - ], - "summary": { + "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 - } - }, - "pagination": { - "hasMore": false + }, + "limitCredits": 20000, + "plan": "pro" } } } } }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" } } } }, - "/api/users/me/usage-logs/export": { + "/api/v2/billing/usage/logs": { "get": { - "operationId": "exportUsageLogs", - "summary": "Export Usage Logs (CSV)", - "description": "Every usage event matching the filter as a CSV download (`Date`, `Type`, `Credits`) — the unpaginated form of `GET /api/users/me/usage-logs`, same auth and workspace-key scoping.", + "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": [ { @@ -1251,7 +1142,7 @@ "schema": { "type": "string" }, - "description": "Restrict to one usage source (e.g. `workflow`, `copilot`). Omit for all sources." + "description": "Restrict to one usage source (e.g. `workflow`, `copilot`)." }, { "name": "workspaceId", @@ -1289,24 +1180,96 @@ "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": "CSV attachment. `X-Export-Truncated: 1` signals the 50,000-row safety cap was hit.", + "description": "A page of usage events.", "content": { - "text/csv": { + "application/json": { "schema": { - "type": "string" + "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/BadRequest" + }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" } } } @@ -1967,79 +1930,6 @@ } } }, - "Limits": { - "type": "object", - "description": "Rate limit and usage information included in every API response.", - "properties": { - "workflowExecutionRateLimit": { - "type": "object", - "description": "Current rate limit status for workflow executions.", - "properties": { - "sync": { - "description": "Rate limit bucket for synchronous executions.", - "$ref": "#/components/schemas/RateLimitBucket" - }, - "async": { - "description": "Rate limit bucket for asynchronous executions.", - "$ref": "#/components/schemas/RateLimitBucket" - } - } - }, - "usage": { - "type": "object", - "description": "Current billing period usage and plan limits.", - "properties": { - "currentPeriodCost": { - "type": "number", - "description": "Total spend in the current billing period in USD.", - "example": 1.25 - }, - "limit": { - "type": "number", - "description": "Maximum allowed spend for the current billing period in USD.", - "example": 50 - }, - "plan": { - "type": "string", - "description": "Your current subscription plan (e.g., free, pro, team).", - "example": "pro" - }, - "isExceeded": { - "type": "boolean", - "description": "Whether the usage limit has been exceeded. Executions may be blocked when true.", - "example": false - } - } - } - } - }, - "RateLimitBucket": { - "type": "object", - "description": "Rate limit status for a specific execution type.", - "properties": { - "requestsPerMinute": { - "type": "integer", - "description": "Maximum number of requests allowed per minute.", - "example": 60 - }, - "maxBurst": { - "type": "integer", - "description": "Maximum number of concurrent requests allowed in a burst.", - "example": 10 - }, - "remaining": { - "type": "integer", - "description": "Number of requests remaining in the current rate limit window.", - "example": 59 - }, - "resetAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the rate limit window resets.", - "example": "2025-06-20T14:16:00Z" - } - } - }, "JobStatus": { "type": "object", "description": "Status of an asynchronous job.", @@ -2314,56 +2204,12 @@ }, "UsageLimits": { "type": "object", - "description": "Current rate limits, usage, and storage information for the authenticated user.", + "description": "Current usage and storage information for the authenticated user.", "properties": { "success": { "type": "boolean", "description": "Whether the request was successful." }, - "rateLimit": { - "type": "object", - "description": "Rate limit status for workflow executions.", - "properties": { - "sync": { - "description": "Rate limit bucket for synchronous executions.", - "allOf": [ - { - "$ref": "#/components/schemas/RateLimitBucket" - }, - { - "type": "object", - "properties": { - "isLimited": { - "type": "boolean", - "description": "Whether the rate limit has been reached." - } - } - } - ] - }, - "async": { - "description": "Rate limit bucket for asynchronous executions.", - "allOf": [ - { - "$ref": "#/components/schemas/RateLimitBucket" - }, - { - "type": "object", - "properties": { - "isLimited": { - "type": "boolean", - "description": "Whether the rate limit has been reached." - } - } - } - ] - }, - "authType": { - "type": "string", - "description": "The authentication type used (api or manual)." - } - } - }, "usage": { "type": "object", "description": "Current billing period usage.", 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/export/route.ts b/apps/sim/app/api/users/me/usage-logs/export/route.ts index 052ed96de6c..d32e6d59837 100644 --- a/apps/sim/app/api/users/me/usage-logs/export/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/export/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { exportUsageLogsContract } from '@/lib/api/contracts/user' import { parseRequest } from '@/lib/api/server' -import { checkHybridAuth } from '@/lib/auth/hybrid' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { getUsageCreditsByLogId, getUserUsageLogs, @@ -10,10 +10,7 @@ import { } from '@/lib/billing/core/usage-log' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { formatCsvValue, toCsvRow } from '@/lib/table/export-format' -import { - resolveDateRange, - resolveUsageLogsWorkspaceFilter, -} from '@/app/api/users/me/usage-logs/shared' +import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' import { USAGE_LOG_SOURCE_LABELS } from '@/app/api/users/me/usage-logs/source-labels' const logger = createLogger('UsageLogsExportAPI') @@ -36,7 +33,7 @@ const CSV_HEADER = toCsvRow(['Date', 'Type', 'Credits']) * (unlike, say, a workspace's full execution history). */ export const GET = withRouteHandler(async (request: NextRequest) => { - const auth = await checkHybridAuth(request, { requireWorkflowId: false }) + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -45,13 +42,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const { source, workspaceId, period, startDate, endDate } = parsed.data.query - const workspaceFilter = resolveUsageLogsWorkspaceFilter(auth, workspaceId) - if (!workspaceFilter.ok) return workspaceFilter.response - const dateRange = resolveDateRange(period, startDate, endDate) const filter = { source: source as UsageLogSource | undefined, - workspaceId: workspaceFilter.workspaceId, + workspaceId, startDate: dateRange.startDate, endDate: dateRange.endDate, } 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 256f73b9f48..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 @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { authMockFns, createMockRequest, hybridAuthMockFns } from '@sim/testing' +import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { apportionCredits } from '@/lib/billing/credits/conversion' @@ -46,58 +46,6 @@ describe('GET /api/users/me/usage-logs', () => { expect(response.status).toBe(401) }) - it('accepts a personal API key and reads that user’s ledger unscoped', async () => { - hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ - success: true, - userId: 'key-owner', - authType: 'api_key', - apiKeyType: 'personal', - }) - - const response = await GET(createMockRequest('GET')) - - expect(response.status).toBe(200) - expect(mockGetUserUsageLogs).toHaveBeenCalledWith( - 'key-owner', - expect.objectContaining({ workspaceId: undefined }) - ) - }) - - it('pins a workspace API key to its own workspace’s slice of the ledger', async () => { - hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ - success: true, - userId: 'key-owner', - workspaceId: 'ws-1', - authType: 'api_key', - apiKeyType: 'workspace', - }) - - const response = await GET(createMockRequest('GET')) - - expect(response.status).toBe(200) - expect(mockGetUserUsageLogs).toHaveBeenCalledWith( - 'key-owner', - expect.objectContaining({ workspaceId: 'ws-1' }) - ) - }) - - it('rejects a workspace API key asking for a different workspace', async () => { - hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ - success: true, - userId: 'key-owner', - workspaceId: 'ws-1', - authType: 'api_key', - apiKeyType: 'workspace', - }) - - const response = await GET( - createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?workspaceId=ws-2') - ) - - expect(response.status).toBe(403) - expect(mockGetUserUsageLogs).not.toHaveBeenCalled() - }) - it('converts dollar costs to credits in the logs and summary', async () => { const response = await GET(createMockRequest('GET')) const body = await response.json() @@ -109,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 56d86cba761..9abd381c48c 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { getUsageLogsContract } from '@/lib/api/contracts/user' import { parseRequest } from '@/lib/api/server' -import { checkHybridAuth } from '@/lib/auth/hybrid' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { getUsageCreditsByLogId, getUserUsageLogs, @@ -10,24 +10,17 @@ import { } from '@/lib/billing/core/usage-log' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - resolveDateRange, - resolveUsageLogsWorkspaceFilter, -} from '@/app/api/users/me/usage-logs/shared' +import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' 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. - * - * Accepts session auth AND `X-API-Key` (matching `/api/users/me/usage-limits`, - * whose aggregate `currentPeriodCost` this endpoint's `summary` breaks down by - * source) so external monitors can watch e.g. Copilot consumption. Workspace - * keys are pinned to their own workspace's slice of the ledger. + * Session-only — the API-key-facing equivalent is `GET /api/v2/billing/usage/logs`. */ export const GET = withRouteHandler(async (request: NextRequest) => { - const auth = await checkHybridAuth(request, { requireWorkflowId: false }) + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -37,14 +30,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { source, workspaceId, period, startDate, endDate, limit, cursor, includeCredits } = parsed.data.query - const workspaceFilter = resolveUsageLogsWorkspaceFilter(auth, workspaceId) - if (!workspaceFilter.ok) return workspaceFilter.response - const dateRange = resolveDateRange(period, startDate, endDate) const filter = { source: source as UsageLogSource | undefined, - workspaceId: workspaceFilter.workspaceId, + workspaceId, startDate: dateRange.startDate, endDate: dateRange.endDate, } @@ -62,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/users/me/usage-logs/shared.ts b/apps/sim/app/api/users/me/usage-logs/shared.ts index e751ec52a87..1c143248edc 100644 --- a/apps/sim/app/api/users/me/usage-logs/shared.ts +++ b/apps/sim/app/api/users/me/usage-logs/shared.ts @@ -1,38 +1,7 @@ -import { NextResponse } from 'next/server' import type { UsageLogPeriod } from '@/lib/api/contracts/user' -import type { AuthResult } from '@/lib/auth/hybrid' const PERIOD_TO_DAYS: Record<'1d' | '7d' | '30d', number> = { '1d': 1, '7d': 7, '30d': 30 } -type WorkspaceFilterResult = - | { ok: true; workspaceId: string | undefined } - | { ok: false; response: NextResponse } - -/** - * Resolves the effective `workspaceId` ledger filter for the caller's - * credential. Sessions, internal JWTs, and personal API keys read the - * authenticated user's full ledger with whatever filter they asked for; a - * workspace-scoped API 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 resolveUsageLogsWorkspaceFilter( - auth: AuthResult, - requestedWorkspaceId: string | undefined -): WorkspaceFilterResult { - if (auth.apiKeyType !== 'workspace') return { ok: true, workspaceId: requestedWorkspaceId } - if (requestedWorkspaceId && requestedWorkspaceId !== auth.workspaceId) { - return { - ok: false, - response: NextResponse.json( - { error: 'API key is not authorized for this workspace' }, - { status: 403 } - ), - } - } - return { ok: true, workspaceId: auth.workspaceId } -} - interface ResolvedDateRange { startDate: Date | undefined endDate: Date diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 385ad2b364a..1eb3d86acfe 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -44,6 +44,7 @@ export type ApiEndpoint = | 'knowledge-detail' | 'knowledge-search' | 'copilot-chat' + | 'billing-usage' export interface RateLimitResult { allowed: boolean 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..88cde13aa69 --- /dev/null +++ b/apps/sim/app/api/v2/billing/usage/logs/route.test.ts @@ -0,0 +1,131 @@ +/** + * @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, +})) + +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..531f87ea9ef --- /dev/null +++ b/apps/sim/app/api/v2/billing/usage/logs/route.ts @@ -0,0 +1,87 @@ +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 { + 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 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..da2bf7cb2f8 --- /dev/null +++ b/apps/sim/app/api/v2/billing/usage/route.test.ts @@ -0,0 +1,140 @@ +/** + * @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, +})) + +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..1c17468ce73 --- /dev/null +++ b/apps/sim/app/api/v2/billing/usage/route.ts @@ -0,0 +1,87 @@ +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 { 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 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/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 320e41f16ff..b3498e488e3 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]/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/lib/api/contracts/user.ts b/apps/sim/lib/api/contracts/user.ts index c799745bf1d..a2604b47589 100644 --- a/apps/sim/lib/api/contracts/user.ts +++ b/apps/sim/lib/api/contracts/user.ts @@ -341,12 +341,16 @@ export const usageLogEntrySchema = z.object({ * Credit-denominated cost of this event (Sim's usage unit; 1,000 credits = * $5), apportioned across the page so row credits always sum exactly to * the page's rounded total — this can legitimately be 0 for a row with a - * real but sub-credit `dollarCost` once a sibling row absorbs the shared - * rounding remainder. + * real but sub-credit charge once a sibling row absorbs the shared + * rounding remainder (see `hasCost`). */ creditCost: z.number(), - /** Raw dollar cost, so a 0 `creditCost` can be distinguished from a genuinely free event. */ - dollarCost: z.number(), + /** + * Whether the event carried any real charge — distinguishes a row whose + * `creditCost` apportioned to 0 from a genuinely free event, without putting + * raw dollar costs on the wire. + */ + hasCost: z.boolean(), }) export const usageLogsApiResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/v2/billing.ts b/apps/sim/lib/api/contracts/v2/billing.ts new file mode 100644 index 00000000000..6867e587db6 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/billing.ts @@ -0,0 +1,97 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { usageLogPeriodSchema, usageLogSourceSchema } from '@/lib/api/contracts/user' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 billing contracts — the read-only, API-key-facing usage surface. + * + * Deliberately separate from the session-only `/api/users/me/usage-logs` + * endpoints that back the Billing settings UI: the internal surface can evolve + * with the UI, while this one is the versioned public contract for external + * monitors. Everything is credit-denominated (Sim's usage unit; 1,000 credits + * = $5) — raw dollar costs and rate-limit internals are never on this wire. + */ + +/** `Date`-constructor-parseable string; validates parseability, not a wire format. */ +const parseableDateSchema = z + .string() + .min(1) + .refine((value) => !Number.isNaN(Date.parse(value)), { error: 'Invalid date' }) + +export const v2UsageSummaryQuerySchema = z.object({ + /** + * Restrict the breakdown to one workspace. A workspace-scoped API key is + * always pinned to its own workspace; passing a different id returns 403. + */ + workspaceId: z.string().optional(), +}) + +/** + * Current-billing-period usage summary. `bySourceCredits` is the source-aware + * breakdown (workflow, copilot, knowledge-base, …) of the account's ledger for + * the period, so a monitor can watch one source's consumption directly instead + * of estimating it by subtraction. + */ +export const v2UsageSummaryDataSchema = z.object({ + period: z.object({ start: z.string(), end: z.string() }), + totalCredits: z.number(), + bySourceCredits: z.record(z.string(), z.number()), + limitCredits: z.number(), + plan: z.string(), +}) +export type V2UsageSummaryData = z.output + +export const v2GetUsageSummaryContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/billing/usage', + query: v2UsageSummaryQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2UsageSummaryDataSchema), + }, +}) + +export const v2UsageLogsQuerySchema = z + .object({ + source: usageLogSourceSchema.optional(), + /** See {@link v2UsageSummaryQuerySchema}'s `workspaceId` — same pinning rules. */ + workspaceId: z.string().optional(), + period: usageLogPeriodSchema.optional().default('30d'), + /** Required when `period` is `'custom'`. */ + startDate: parseableDateSchema.optional(), + /** Defaults to now when omitted for `'custom'`. */ + endDate: parseableDateSchema.optional(), + limit: z.coerce.number().int().min(1).max(100).optional().default(50), + cursor: z.string().min(1, 'cursor must be a non-empty token').optional(), + }) + .refine((query) => query.period !== 'custom' || query.startDate !== undefined, { + error: 'startDate is required when period is "custom"', + path: ['startDate'], + }) + +/** + * One credit-consuming usage event. `creditCost` is apportioned across the + * page so row credits sum exactly to the page's rounded total; it can + * legitimately be 0 for a sub-credit event once a sibling row absorbs the + * shared rounding remainder. + */ +export const v2UsageLogEntrySchema = z.object({ + id: z.string(), + createdAt: z.string(), + source: usageLogSourceSchema, + /** Populated only when `source` is `'workflow'`. */ + workflowName: z.string().nullable(), + creditCost: z.number(), +}) +export type V2UsageLogEntry = z.output + +export const v2ListUsageLogsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/billing/usage/logs', + query: v2UsageLogsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2UsageLogEntrySchema), + }, +}) diff --git a/apps/sim/lib/billing/credits/conversion.ts b/apps/sim/lib/billing/credits/conversion.ts index 59a11264c85..cea29279136 100644 --- a/apps/sim/lib/billing/credits/conversion.ts +++ b/apps/sim/lib/billing/credits/conversion.ts @@ -67,16 +67,16 @@ export function formatCreditCost( /** * Renders an already-apportioned integer `creditCost` (see {@link apportionCredits}) - * alongside its raw `dollarCost`, so a row can legitimately apportion to 0 - * credits — once a sibling absorbs the shared rounding remainder — without - * reading as a flat, misleading "0 credits" for an event that had a real, - * positive charge. Mirrors {@link formatCreditCost}'s zero/sub-credit - * wording, but never recomputes credits from `dollarCost` (that would - * double-convert a value the caller already apportioned). + * alongside whether the row carried any real charge, so a row can legitimately + * apportion to 0 credits — once a sibling absorbs the shared rounding + * remainder — without reading as a flat, misleading "0 credits" for an event + * that had a real, positive charge. Mirrors {@link formatCreditCost}'s + * zero/sub-credit wording; `hasCost` is a boolean rather than the raw dollar + * cost because dollar amounts never go on the wire. */ -export function formatApportionedCreditCost(creditCost: number, dollarCost: number): string { +export function formatApportionedCreditCost(creditCost: number, hasCost: boolean): string { if (creditCost > 0) return formatCreditsLabel(creditCost) - return dollarCost > 0 ? '<1 credit' : '0 credits' + return hasCost ? '<1 credit' : '0 credits' } /** diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 0aad0ab56ca..8853257bcc2 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1020, - zodRoutes: 1020, + totalRoutes: 1022, + zodRoutes: 1022, nonZodRoutes: 0, } as const From d8021bf02fa133419674aab5ea64fdedd2a1ebd0 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 30 Jul 2026 16:27:32 -0700 Subject: [PATCH 04/24] feat(docs): validate OpenAPI specs against the Zod contracts in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The specs in apps/docs are hand-authored because they carry what Zod never defines — error envelopes, status codes, prose, examples — so they can't be generated; check:openapi validates them instead: - spec integrity: $refs resolve, operationIds unique, 2xx documented, no orphaned component schemas - v2 conventions: every /api/v2 operation documents 401 + 429 and every 4xx/5xx resolves to the canonical { error: { code, message } } envelope - contract cross-check: contracts are auto-discovered from lib/api/contracts/v2 (each carries its method + path); doc<->contract coverage both ways, query/body/response field diffs via z.toJSONSchema - examples: documented request/response examples must parse with the matching contract's actual Zod schemas First run caught real drift, fixed here: 16 stale orphaned schemas in the core spec, the v2 billing ops referencing v1-shaped error components, deploy/rollback examples missing the required nullable lifecycle keys, CreateTableBody missing folderId, a legacy-grammar delete-rows example, and four knowledge document ops missing their required workspaceId query param. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- .github/workflows/test-build.yml | 3 + apps/docs/openapi-core.json | 1082 +++------------------------ apps/docs/openapi-v2-knowledge.json | 52 +- apps/docs/openapi-v2-tables.json | 12 +- apps/docs/openapi-v2-workflows.json | 12 +- package.json | 1 + scripts/check-openapi-specs.ts | 419 +++++++++++ 7 files changed, 594 insertions(+), 987 deletions(-) create mode 100644 scripts/check-openapi-specs.ts diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 262386b6922..6140dbd3ae9 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/openapi-core.json b/apps/docs/openapi-core.json index 1e7e62a7a84..d5d3cccd20e 100644 --- a/apps/docs/openapi-core.json +++ b/apps/docs/openapi-core.json @@ -1109,16 +1109,16 @@ } }, "400": { - "$ref": "#/components/responses/BadRequest" + "$ref": "#/components/responses/V2BadRequest" }, "401": { - "$ref": "#/components/responses/Unauthorized" + "$ref": "#/components/responses/V2Unauthorized" }, "403": { - "$ref": "#/components/responses/Forbidden" + "$ref": "#/components/responses/V2Forbidden" }, "429": { - "$ref": "#/components/responses/RateLimited" + "$ref": "#/components/responses/V2RateLimited" } } } @@ -1260,16 +1260,16 @@ } }, "400": { - "$ref": "#/components/responses/BadRequest" + "$ref": "#/components/responses/V2BadRequest" }, "401": { - "$ref": "#/components/responses/Unauthorized" + "$ref": "#/components/responses/V2Unauthorized" }, "403": { - "$ref": "#/components/responses/Forbidden" + "$ref": "#/components/responses/V2Forbidden" }, "429": { - "$ref": "#/components/responses/RateLimited" + "$ref": "#/components/responses/V2RateLimited" } } } @@ -1316,306 +1316,6 @@ } }, "schemas": { - "ColumnDefinition": { - "type": "object", - "description": "Definition of a table column including its type and constraints.", - "required": ["name", "type"], - "properties": { - "name": { - "type": "string", - "description": "Column name. Must start with a letter or underscore.", - "example": "email", - "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$" - }, - "type": { - "type": "string", - "enum": ["string", "number", "boolean", "date", "json"], - "description": "Data type of the column." - }, - "required": { - "type": "boolean", - "description": "Whether the column requires a value on insert.", - "default": false - }, - "unique": { - "type": "boolean", - "description": "Whether values in this column must be unique across all rows.", - "default": false - } - } - }, - "Table": { - "type": "object", - "description": "A user-defined table with a typed schema.", - "properties": { - "id": { - "type": "string", - "description": "Unique table identifier.", - "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" - }, - "name": { - "type": "string", - "description": "Table name.", - "example": "contacts" - }, - "description": { - "type": "string", - "description": "Optional description of the table.", - "example": "Customer contact records" - }, - "schema": { - "type": "object", - "description": "Table schema definition.", - "properties": { - "columns": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ColumnDefinition" - }, - "description": "Array of column definitions for the table." - } - } - }, - "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." - } - } - }, - "TableRow": { - "type": "object", - "description": "A single row in a table.", - "properties": { - "id": { - "type": "string", - "description": "Unique row identifier.", - "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" - }, - "data": { - "type": "object", - "additionalProperties": true, - "description": "Row data as key-value pairs matching the table schema." - }, - "position": { - "type": "integer", - "description": "Row's position/order in the table." - }, - "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." - } - } - }, - "WorkflowSummary": { - "type": "object", - "description": "Summary representation of a workflow returned in list operations.", - "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.", - "example": "Routes incoming support tickets and drafts responses" - }, - "folderId": { - "type": "string", - "nullable": true, - "description": "The folder this workflow belongs to. null if at the workspace root.", - "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" - }, - "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 if never deployed.", - "example": "2025-06-15T10:30:00Z" - }, - "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 if never run.", - "example": "2025-06-20T14:15:22Z" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the workflow was created.", - "example": "2025-01-10T09:00:00Z" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the workflow was last modified.", - "example": "2025-06-18T16:45:00Z" - } - } - }, - "WorkflowDetail": { - "type": "object", - "description": "Full workflow representation including input field definitions and configuration.", - "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.", - "example": "Routes incoming support tickets and drafts responses" - }, - "folderId": { - "type": "string", - "nullable": true, - "description": "The folder this workflow belongs to. null if at the workspace root.", - "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" - }, - "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 if never deployed.", - "example": "2025-06-15T10:30:00Z" - }, - "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 if never run.", - "example": "2025-06-20T14:15:22Z" - }, - "variables": { - "type": "object", - "description": "Workflow-level variables and their current values.", - "example": {} - }, - "inputs": { - "type": "object", - "description": "The workflow's input field definitions. Use these to construct the input object when executing the workflow.", - "properties": { - "fields": { - "type": "object", - "description": "Map of field names to their type definitions and configuration.", - "additionalProperties": true, - "example": {} - } - } - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the workflow was created.", - "example": "2025-01-10T09:00:00Z" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the workflow was last modified.", - "example": "2025-06-18T16:45:00Z" - } - } - }, - "WorkflowDeployment": { - "type": "object", - "description": "Deployment state of a workflow after a deploy, undeploy, or rollback operation.", - "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.", - "example": true - }, - "deployedAt": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "ISO 8601 timestamp of the active deployment. null after an undeploy.", - "example": "2026-06-12T10:30:00Z" - }, - "version": { - "type": "integer", - "description": "The deployment version that is now active. Omitted for undeploy.", - "example": 4 - }, - "warnings": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Non-fatal warnings. Present when trigger, schedule, or MCP side-effect sync is still in progress or needs a redeploy." - } - } - }, "ExecutionResult": { "type": "object", "description": "Result of a synchronous workflow execution.", @@ -1706,230 +1406,6 @@ } } }, - "LogEntry": { - "type": "object", - "description": "Summary of a single workflow execution log entry.", - "properties": { - "id": { - "type": "string", - "description": "Unique log entry identifier.", - "example": "log_7x8y9z0a1b" - }, - "workflowId": { - "type": "string", - "description": "The workflow that was executed.", - "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, manual, webhook, schedule, chat).", - "example": "api" - }, - "startedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when execution started.", - "example": "2025-06-20T14:15:22Z" - }, - "endedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when execution completed.", - "example": "2025-06-20T14:15:23Z" - }, - "totalDurationMs": { - "type": "integer", - "description": "Total execution duration in milliseconds.", - "example": 1250 - }, - "cost": { - "type": "object", - "description": "Cost summary for this execution.", - "properties": { - "total": { - "type": "number", - "description": "Total cost of this execution in USD.", - "example": 0.0032 - } - } - }, - "files": { - "type": "object", - "nullable": true, - "description": "File outputs produced during execution. null if no files were generated.", - "example": null - } - } - }, - "LogDetail": { - "type": "object", - "description": "Detailed log entry with full execution data, workflow metadata, and cost breakdown.", - "properties": { - "id": { - "type": "string", - "description": "Unique log entry identifier.", - "example": "log_7x8y9z0a1b" - }, - "workflowId": { - "type": "string", - "description": "The workflow that was executed.", - "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, manual, webhook, schedule, chat).", - "example": "api" - }, - "startedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when execution started.", - "example": "2025-06-20T14:15:22Z" - }, - "endedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when execution completed.", - "example": "2025-06-20T14:15:23Z" - }, - "totalDurationMs": { - "type": "integer", - "description": "Total execution duration in milliseconds.", - "example": 1250 - }, - "workflow": { - "type": "object", - "description": "Summary metadata about the workflow at the time of execution.", - "properties": { - "id": { - "type": "string", - "description": "Unique workflow identifier.", - "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" - }, - "name": { - "type": "string", - "description": "Workflow name at the time of execution.", - "example": "Customer Support Agent" - }, - "description": { - "type": "string", - "nullable": true, - "description": "Workflow description at the time of execution.", - "example": "Routes incoming support tickets and drafts responses" - } - } - }, - "executionData": { - "type": "object", - "description": "Detailed execution data including block-level traces and final output.", - "properties": { - "traceSpans": { - "type": "array", - "description": "Block-level execution traces with timing, inputs, and outputs for each block that ran.", - "items": { - "type": "object" - } - }, - "finalOutput": { - "type": "object", - "description": "The workflow's final output after all blocks completed." - } - } - }, - "cost": { - "type": "object", - "description": "Detailed cost breakdown for this execution.", - "properties": { - "total": { - "type": "number", - "description": "Total cost of this execution in USD.", - "example": 0.0032 - }, - "tokens": { - "type": "object", - "description": "Aggregate token usage across all AI model calls in this execution.", - "properties": { - "prompt": { - "type": "integer", - "description": "Total prompt (input) tokens consumed.", - "example": 450 - }, - "completion": { - "type": "integer", - "description": "Total completion (output) tokens generated.", - "example": 120 - }, - "total": { - "type": "integer", - "description": "Total tokens (prompt + completion).", - "example": 570 - } - } - }, - "models": { - "type": "object", - "description": "Per-model cost and token breakdown. Keys are model identifiers (e.g., gpt-4o, claude-sonnet-4-20250514).", - "additionalProperties": { - "type": "object", - "description": "Cost and token details for a specific model.", - "properties": { - "input": { - "type": "number", - "description": "Cost of prompt tokens for this model in USD." - }, - "output": { - "type": "number", - "description": "Cost of completion tokens for this model in USD." - }, - "total": { - "type": "number", - "description": "Total cost for this model in USD." - }, - "tokens": { - "type": "object", - "description": "Token usage for this specific model.", - "properties": { - "prompt": { - "type": "integer", - "description": "Prompt tokens consumed by this model." - }, - "completion": { - "type": "integer", - "description": "Completion tokens generated by this model." - }, - "total": { - "type": "integer", - "description": "Total tokens for this model." - } - } - } - } - } - } - } - } - } - }, "JobStatus": { "type": "object", "description": "Status of an asynchronous job.", @@ -2082,123 +1558,48 @@ "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" - } - } - } - }, - "AuditLogEntry": { - "type": "object", - "description": "An enterprise audit log entry recording an action taken in the workspace.", - "properties": { - "id": { - "type": "string", - "description": "Unique identifier for the audit log entry.", - "example": "audit_2c3d4e5f6g" - }, - "workspaceId": { - "type": "string", - "nullable": true, - "description": "The workspace where the action occurred.", - "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" - }, - "actorId": { - "type": "string", - "nullable": true, - "description": "The user ID of the person who performed the action.", - "example": "user_abc123" - }, - "actorName": { - "type": "string", - "nullable": true, - "description": "Display name of the person who performed the action.", - "example": "Jane Smith" - }, - "actorEmail": { - "type": "string", - "nullable": true, - "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., workflow.created, member.invited).", - "example": "workflow.deployed" - }, - "resourceType": { - "type": "string", - "description": "The type of resource affected (e.g., workflow, workspace, member).", - "example": "workflow" - }, - "resourceId": { - "type": "string", - "nullable": true, - "description": "The unique identifier of the affected resource.", - "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + "example": 1 + }, + "resumedCount": { + "type": "integer", + "description": "Number of pause points already resumed.", + "example": 0 + } + } }, - "resourceName": { - "type": "string", + "cost": { + "type": "object", "nullable": true, - "description": "Display name of the affected resource.", - "example": "Customer Support Agent" + "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 + } + } }, - "description": { + "error": { "type": "string", "nullable": true, - "description": "Human-readable description of the action.", - "example": "Deployed workflow Customer Support Agent" + "description": "Error message. Present only when status is `failed`.", + "example": null }, - "metadata": { + "finalOutput": { "type": "object", "nullable": true, - "description": "Additional context about the action.", + "description": "The workflow's final output. Returned only when ?includeOutput=true AND status is `completed`.", "example": null }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the action occurred.", - "example": "2025-06-20T14:15:22Z" + "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" + } } } }, @@ -2248,347 +1649,6 @@ } } }, - "FileMetadata": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique file identifier.", - "example": "wf_V1StGXR8z5jdHi6BmyT91" - }, - "name": { - "type": "string", - "description": "Original filename.", - "example": "data.csv" - }, - "size": { - "type": "integer", - "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/abc-123/1709571234-xyz-data.csv" - }, - "uploadedBy": { - "type": "string", - "description": "User ID of the uploader." - }, - "uploadedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp of when the file was uploaded." - } - } - }, - "KnowledgeBase": { - "type": "object", - "description": "A knowledge base for storing and searching document embeddings.", - "properties": { - "id": { - "type": "string", - "description": "Unique knowledge base identifier." - }, - "name": { - "type": "string", - "description": "Knowledge base name." - }, - "description": { - "type": "string", - "nullable": true, - "description": "Optional description." - }, - "tokenCount": { - "type": "integer", - "description": "Total token count across all documents." - }, - "embeddingModel": { - "type": "string", - "description": "Embedding model used (e.g. text-embedding-3-small)." - }, - "embeddingDimension": { - "type": "integer", - "description": "Embedding vector dimension." - }, - "chunkingConfig": { - "$ref": "#/components/schemas/ChunkingConfig" - }, - "docCount": { - "type": "integer", - "description": "Number of documents in the knowledge base." - }, - "connectorTypes": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Types of connectors attached to this knowledge base." - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the knowledge base was created." - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the knowledge base was last modified." - } - } - }, - "ChunkingConfig": { - "type": "object", - "description": "Configuration for how documents are split into chunks for embedding.", - "properties": { - "maxSize": { - "type": "integer", - "minimum": 100, - "maximum": 4000, - "default": 1024, - "description": "Maximum chunk size in tokens." - }, - "minSize": { - "type": "integer", - "minimum": 1, - "maximum": 2000, - "default": 100, - "description": "Minimum chunk size in characters." - }, - "overlap": { - "type": "integer", - "minimum": 0, - "maximum": 500, - "default": 200, - "description": "Overlap between chunks in tokens." - } - } - }, - "KnowledgeDocument": { - "type": "object", - "description": "A document in a knowledge base.", - "properties": { - "id": { - "type": "string", - "description": "Unique document identifier." - }, - "knowledgeBaseId": { - "type": "string", - "description": "Knowledge base this document belongs to." - }, - "filename": { - "type": "string", - "description": "Original filename." - }, - "fileSize": { - "type": "integer", - "description": "File size in bytes." - }, - "mimeType": { - "type": "string", - "description": "MIME type of the file." - }, - "processingStatus": { - "type": "string", - "enum": ["pending", "processing", "completed", "failed"], - "description": "Current processing status." - }, - "chunkCount": { - "type": "integer", - "description": "Number of chunks created from this document." - }, - "tokenCount": { - "type": "integer", - "description": "Total token count." - }, - "characterCount": { - "type": "integer", - "description": "Total character count." - }, - "enabled": { - "type": "boolean", - "description": "Whether the document is enabled for search." - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the document was uploaded." - } - } - }, - "KnowledgeDocumentDetail": { - "type": "object", - "description": "Detailed document information including processing and connector details.", - "properties": { - "id": { - "type": "string", - "description": "Unique document identifier." - }, - "knowledgeBaseId": { - "type": "string", - "description": "Knowledge base this document belongs to." - }, - "filename": { - "type": "string", - "description": "Original filename." - }, - "fileSize": { - "type": "integer", - "description": "File size in bytes." - }, - "mimeType": { - "type": "string", - "description": "MIME type of the file." - }, - "processingStatus": { - "type": "string", - "enum": ["pending", "processing", "completed", "failed"], - "description": "Current processing status." - }, - "processingError": { - "type": "string", - "nullable": true, - "description": "Error message if processing failed." - }, - "processingStartedAt": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "When processing started." - }, - "processingCompletedAt": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "When processing completed." - }, - "chunkCount": { - "type": "integer", - "description": "Number of chunks created." - }, - "tokenCount": { - "type": "integer", - "description": "Total token count." - }, - "characterCount": { - "type": "integer", - "description": "Total character count." - }, - "enabled": { - "type": "boolean", - "description": "Whether the document is enabled for search." - }, - "connectorId": { - "type": "string", - "nullable": true, - "description": "Connector ID if sourced from an external connector." - }, - "connectorType": { - "type": "string", - "nullable": true, - "description": "Connector type (e.g. google-drive, notion)." - }, - "sourceUrl": { - "type": "string", - "nullable": true, - "description": "Original source URL for connector-sourced documents." - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the document was uploaded." - } - } - }, - "SearchResult": { - "type": "object", - "description": "A single search result from knowledge base vector search.", - "properties": { - "documentId": { - "type": "string", - "description": "ID of the source document." - }, - "documentName": { - "type": "string", - "description": "Filename of the source document." - }, - "sourceUrl": { - "type": "string", - "nullable": true, - "description": "URL to the original source document for connector-synced documents (e.g., a Confluence page, Google Doc, or Notion page). Null for documents without an external source." - }, - "content": { - "type": "string", - "description": "The matched chunk content." - }, - "chunkIndex": { - "type": "integer", - "description": "Index of the chunk within the document." - }, - "metadata": { - "type": "object", - "description": "Tag metadata associated with the chunk (display names mapped to values)." - }, - "similarity": { - "type": "number", - "minimum": 0, - "maximum": 1, - "description": "Similarity score (0-1, where 1 is most similar)." - } - } - }, - "TagFilter": { - "type": "object", - "description": "A tag-based filter for knowledge base search.", - "required": ["tagName", "value"], - "properties": { - "tagName": { - "type": "string", - "description": "Display name of the tag to filter by." - }, - "fieldType": { - "type": "string", - "enum": ["text", "number", "date", "boolean"], - "default": "text", - "description": "Data type of the tag field." - }, - "operator": { - "type": "string", - "default": "eq", - "description": "Comparison operator (e.g. eq, neq, gt, lt, gte, lte, contains, between)." - }, - "value": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ], - "description": "Value to filter by." - }, - "valueTo": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ], - "description": "Upper bound value for 'between' operator." - } - } - }, "PausedExecutionSummary": { "type": "object", "description": "Summary of a paused workflow execution.", @@ -2928,6 +1988,28 @@ } } } + }, + "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": { @@ -3073,6 +2155,46 @@ } } } + }, + "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-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 5c43fd27ff7..ef6de06096b 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -564,6 +564,16 @@ "enum": ["asc", "desc"], "default": "desc" } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "Workspace that owns the knowledge base." } ], "responses": { @@ -727,7 +737,19 @@ "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/{documentId}": { @@ -791,7 +813,19 @@ "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", @@ -845,7 +879,19 @@ "500": { "$ref": "#/components/responses/InternalError" } - } + }, + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "Workspace that owns the knowledge base." + } + ] } } }, diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 3560bf37155..acb09fa38df 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -950,7 +950,13 @@ "value": { "workspaceId": "YOUR_WORKSPACE_ID", "filter": { - "status": "archived" + "all": [ + { + "field": "status", + "op": "eq", + "value": "archived" + } + ] } } } @@ -1809,6 +1815,10 @@ } } } + }, + "folderId": { + "type": ["string", "null"], + "description": "Folder to create the table in. Omitted or null creates it at the workspace root." } } }, diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 4816d764806..79a91ab0eb6 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -334,7 +334,9 @@ "isDeployed": true, "deployedAt": "2026-06-12T10:30:00.000Z", "version": 4, - "warnings": [] + "warnings": [], + "activeDeployment": null, + "latestDeploymentAttempt": null } } } @@ -411,7 +413,9 @@ "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "isDeployed": false, "deployedAt": null, - "warnings": [] + "warnings": [], + "activeDeployment": null, + "latestDeploymentAttempt": null } } } @@ -508,7 +512,9 @@ "isDeployed": true, "deployedAt": "2026-06-12T10:30:00.000Z", "version": 3, - "warnings": [] + "warnings": [], + "activeDeployment": null, + "latestDeploymentAttempt": null } } } diff --git a/package.json b/package.json index fec0621c543..57fdd73e731 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "check": "turbo run format:check", "check:boundaries": "bun run scripts/check-monorepo-boundaries.ts", "check:api-validation": "bun run scripts/check-api-validation-contracts.ts --check", + "check:openapi": "bun run scripts/check-openapi-specs.ts", "check:api-validation:strict": "bun run scripts/check-api-validation-contracts.ts --check --enforce-boundary-baseline", "check:realtime-prune": "bun run scripts/check-realtime-prune-graph.ts", "check:zustand-v5": "bun run scripts/check-zustand-v5-selectors.ts", diff --git a/scripts/check-openapi-specs.ts b/scripts/check-openapi-specs.ts new file mode 100644 index 00000000000..87cab72e160 --- /dev/null +++ b/scripts/check-openapi-specs.ts @@ -0,0 +1,419 @@ +#!/usr/bin/env bun +/** + * Validates the hand-authored OpenAPI specs in `apps/docs/` against each other + * and against the runtime Zod contracts in `apps/sim/lib/api/contracts/`. + * + * The Zod contracts are the runtime source of truth for *success* request and + * response shapes, but the specs additionally carry what Zod never defines — + * error envelopes, status codes, prose, and examples — so the specs cannot be + * generated and must instead be checked: + * + * 1. Spec integrity (every file): all `$ref`s resolve, operationIds are + * present and unique, every operation documents a success response, no + * orphaned component schemas. + * 2. v2 conventions (every `/api/v2/` operation): 401 and 429 are documented, + * and every documented 4xx/5xx resolves to the canonical error envelope + * `{ error: { code, message } }`. + * 3. Contract cross-check: every contract exported from + * `lib/api/contracts/v2/*` must be documented, every documented `/api/v2/` + * operation must have a contract, and for each pair the query params, + * body fields, and response fields are diffed via `z.toJSONSchema`. + * 4. Examples: documented request/response examples are parsed with the + * matching contract's actual Zod schemas — a doc example that the runtime + * would reject fails the build. + */ + +import { readdirSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { z } from 'zod' + +const ROOT = path.resolve(import.meta.dir, '..') +const DOCS_DIR = path.join(ROOT, 'apps/docs') +const V2_CONTRACTS_DIR = path.join(ROOT, 'apps/sim/lib/api/contracts/v2') + +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', +] + +/** Extra non-v2 contracts that are documented in the core spec. */ +const EXTRA_CONTRACT_MODULES = [path.join(ROOT, 'apps/sim/lib/api/contracts/usage-limits.ts')] + +type Json = Record +const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete']) + +const errors: string[] = [] +const fail = (spec: string, msg: string) => errors.push(`${spec}: ${msg}`) + +interface ContractLike { + method: string + path: string + params?: z.ZodType + query?: z.ZodType + body?: z.ZodType + response?: { mode: string; schema?: z.ZodType } +} + +function isContract(value: unknown): value is ContractLike { + return ( + !!value && + typeof value === 'object' && + typeof (value as ContractLike).method === 'string' && + typeof (value as ContractLike).path === 'string' && + typeof (value as ContractLike).response === 'object' + ) +} + +/** `[tableId]` (contract) → `{tableId}` (OpenAPI). */ +const contractKey = (c: ContractLike) => + `${c.method.toUpperCase()} ${c.path.replace(/\[([^\]]+)\]/g, '{$1}')}` + +async function loadContracts(): Promise> { + const registry = new Map() + const files = readdirSync(V2_CONTRACTS_DIR) + .filter((f) => f.endsWith('.ts') && f !== 'shared.ts') + .map((f) => path.join(V2_CONTRACTS_DIR, f)) + for (const file of [...files, ...EXTRA_CONTRACT_MODULES]) { + const mod = (await import(file)) as Record + for (const [name, value] of Object.entries(mod)) { + if (!isContract(value)) continue + const key = contractKey(value) + const existing = registry.get(key) + if (existing) { + // A route may expose narrowing variants of one operation (e.g. the + // batch-create alias) — keep the first, they share the wire. + continue + } + registry.set(key, { name, contract: value }) + } + } + return registry +} + +function resolveRef(ref: string, spec: Json): unknown { + let current: unknown = spec + for (const part of ref.replace('#/', '').split('/')) { + if (!current || typeof current !== 'object') return undefined + current = (current as Json)[part] + } + return current +} + +/** Follow at most one level of `$ref` chains until a concrete node. */ +function deref(node: unknown, spec: Json): unknown { + let current = node + for (let i = 0; i < 8; i++) { + if (current && typeof current === 'object' && typeof (current as Json).$ref === 'string') { + current = resolveRef((current as Json).$ref as string, spec) + } else { + return current + } + } + return current +} + +function walkRefs(node: unknown, visit: (ref: string) => void): void { + if (Array.isArray(node)) { + for (const item of node) walkRefs(item, visit) + } else if (node && typeof node === 'object') { + for (const [key, value] of Object.entries(node)) { + if (key === '$ref' && typeof value === 'string') visit(value) + else walkRefs(value, visit) + } + } +} + +/** + * Top-level property names of a documented JSON schema, unioning `oneOf` / + * `anyOf` / `allOf` variants. Returns `null` when the schema is opaque + * (no `properties` anywhere), in which case comparison is skipped. + */ +function docPropertyNames(schema: unknown, spec: Json): Set | null { + const node = deref(schema, spec) + if (!node || typeof node !== 'object') return null + const record = node as Json + const variants = (record.oneOf ?? record.anyOf ?? record.allOf) as unknown[] | undefined + if (variants) { + const names = new Set() + let sawAny = false + for (const variant of variants) { + const sub = docPropertyNames(variant, spec) + if (sub) { + sawAny = true + for (const n of sub) names.add(n) + } + } + return sawAny ? names : null + } + if (record.properties && typeof record.properties === 'object') { + return new Set(Object.keys(record.properties as Json)) + } + return null +} + +/** Same union logic over the Zod-derived JSON schema (no `$ref`s inside). */ +function zodPropertyNames(schema: unknown): Set | null { + return docPropertyNames(schema, {} as Json) +} + +function toJsonSchema(schema: z.ZodType, io: 'input' | 'output'): Json | null { + try { + return z.toJSONSchema(schema, { io, unrepresentable: 'any' }) as Json + } catch { + return null + } +} + +interface Operation { + specFile: string + path: string + method: string + op: Json + spec: Json +} + +function collectOperations(specFile: string, spec: Json): Operation[] { + const ops: Operation[] = [] + for (const [p, methods] of Object.entries((spec.paths as Json) ?? {})) { + if (!methods || typeof methods !== 'object') continue + for (const [method, op] of Object.entries(methods as Json)) { + if (!HTTP_METHODS.has(method)) continue + ops.push({ specFile, path: p, method, op: op as Json, spec }) + } + } + return ops +} + +function checkIntegrity(specFile: string, spec: Json, ops: Operation[]): void { + walkRefs(spec, (ref) => { + if (resolveRef(ref, spec) === undefined) fail(specFile, `unresolved $ref ${ref}`) + }) + + const seenIds = new Set() + for (const { path: p, method, op } of ops) { + const label = `${method.toUpperCase()} ${p}` + const id = op.operationId + if (typeof id !== 'string' || !id) { + fail(specFile, `${label}: missing operationId`) + } else if (seenIds.has(id)) { + fail(specFile, `${label}: duplicate operationId "${id}"`) + } else { + seenIds.add(id) + } + const responses = (op.responses as Json) ?? {} + if (!Object.keys(responses).some((code) => code.startsWith('2'))) { + fail(specFile, `${label}: no documented 2xx response`) + } + } + + const schemas = ((spec.components as Json)?.schemas as Json) ?? {} + const blobWithout = (name: string) => + JSON.stringify({ + ...spec, + components: { ...(spec.components as Json), schemas: { ...schemas, [name]: null } }, + }) + for (const name of Object.keys(schemas)) { + if (!blobWithout(name).includes(`"#/components/schemas/${name}"`)) { + fail(specFile, `orphaned component schema "${name}" (unreferenced)`) + } + } +} + +function checkV2Conventions(operation: Operation): void { + const { specFile, path: p, method, op, spec } = operation + const label = `${method.toUpperCase()} ${p}` + const responses = (op.responses as Json) ?? {} + + for (const code of ['401', '429']) { + if (!(code in responses)) fail(specFile, `${label}: v2 operation missing ${code} response`) + } + + for (const [code, response] of Object.entries(responses)) { + if (!/^[45]/.test(code)) continue + const resolved = deref(response, spec) as Json | undefined + const schema = deref( + ((resolved?.content as Json)?.['application/json'] as Json)?.schema, + spec + ) as Json | undefined + // A bodyless error (e.g. a bare 413) documents intent without a schema. + if (!schema) continue + const errorProp = deref((schema.properties as Json)?.error, spec) as Json | undefined + const inner = errorProp?.properties as Json | undefined + if (!inner || !('code' in inner) || !('message' in inner)) { + fail( + specFile, + `${label}: ${code} response is not the canonical v2 error envelope { error: { code, message } }` + ) + } + } +} + +function checkQueryParams(operation: Operation, contract: ContractLike, name: string): void { + const { specFile, path: p, method, op, spec } = operation + const label = `${method.toUpperCase()} ${p}` + if (!contract.query) return + const zodSchema = toJsonSchema(contract.query, 'input') + if (!zodSchema?.properties) return + + const docParams = new Map() + for (const raw of (op.parameters as unknown[]) ?? []) { + const param = deref(raw, spec) as Json | undefined + if (param?.in === 'query' && typeof param.name === 'string') docParams.set(param.name, param) + } + + const zodProps = Object.keys(zodSchema.properties as Json) + const zodRequired = new Set((zodSchema.required as string[]) ?? []) + for (const prop of zodProps) { + const doc = docParams.get(prop) + if (!doc) { + fail(specFile, `${label}: query param "${prop}" (${name}) is not documented`) + } else if (Boolean(doc.required) !== zodRequired.has(prop)) { + fail( + specFile, + `${label}: query param "${prop}" required mismatch (contract ${zodRequired.has(prop) ? 'required' : 'optional'}, docs ${doc.required ? 'required' : 'optional'})` + ) + } + } + for (const docName of docParams.keys()) { + if (!zodProps.includes(docName)) { + fail(specFile, `${label}: documented query param "${docName}" does not exist on ${name}`) + } + } +} + +function checkBodyAndResponse(operation: Operation, contract: ContractLike, name: string): void { + const { specFile, path: p, method, op, spec } = operation + const label = `${method.toUpperCase()} ${p}` + + const docBodySchema = ((deref(op.requestBody, spec) as Json)?.content as Json)?.[ + 'application/json' + ] as Json | undefined + if (contract.body && docBodySchema?.schema) { + const zodNames = zodPropertyNames(toJsonSchema(contract.body, 'input')) + const docNames = docPropertyNames(docBodySchema.schema, spec) + if (zodNames && docNames) { + for (const n of zodNames) { + if (!docNames.has(n)) fail(specFile, `${label}: body field "${n}" (${name}) not documented`) + } + for (const n of docNames) { + if (!zodNames.has(n)) { + fail(specFile, `${label}: documented body field "${n}" does not exist on ${name}`) + } + } + } + } + + if (contract.response?.mode === 'json' && contract.response.schema) { + const responses = (op.responses as Json) ?? {} + const successCode = Object.keys(responses).find((code) => code.startsWith('2')) + const docResponse = successCode ? (deref(responses[successCode], spec) as Json) : undefined + const docSchema = ((docResponse?.content as Json)?.['application/json'] as Json)?.schema + if (docSchema) { + const zodNames = zodPropertyNames(toJsonSchema(contract.response.schema, 'output')) + const docNames = docPropertyNames(docSchema, spec) + if (zodNames && docNames) { + for (const n of zodNames) { + if (!docNames.has(n)) { + fail(specFile, `${label}: response field "${n}" (${name}) not documented`) + } + } + for (const n of docNames) { + if (!zodNames.has(n)) { + fail(specFile, `${label}: documented response field "${n}" does not exist on ${name}`) + } + } + } + } + } +} + +function checkExamples(operation: Operation, contract: ContractLike, name: string): void { + const { specFile, path: p, method, op, spec } = operation + const label = `${method.toUpperCase()} ${p}` + + const bodyContent = ((deref(op.requestBody, spec) as Json)?.content as Json)?.[ + 'application/json' + ] as Json | undefined + if (contract.body && bodyContent) { + const candidates: Array<[string, unknown]> = [] + if (bodyContent.example !== undefined) candidates.push(['example', bodyContent.example]) + for (const [exName, ex] of Object.entries((bodyContent.examples as Json) ?? {})) { + candidates.push([exName, (ex as Json).value]) + } + for (const [exName, value] of candidates) { + const parsed = contract.body.safeParse(value) + if (!parsed.success) { + fail( + specFile, + `${label}: request example "${exName}" rejected by ${name}: ${parsed.error.issues[0]?.message}` + ) + } + } + } + + if (contract.response?.mode === 'json' && contract.response.schema) { + const responses = (op.responses as Json) ?? {} + const successCode = Object.keys(responses).find((code) => code.startsWith('2')) + const docResponse = successCode ? (deref(responses[successCode], spec) as Json) : undefined + const content = (docResponse?.content as Json)?.['application/json'] as Json | undefined + if (content?.example !== undefined) { + const parsed = contract.response.schema.safeParse(content.example) + if (!parsed.success) { + const issue = parsed.error.issues[0] + fail( + specFile, + `${label}: response example rejected by ${name} at ${issue?.path.join('.') || ''}: ${issue?.message}` + ) + } + } + } +} + +const registry = await loadContracts() +const documentedKeys = new Set() + +for (const specFile of SPEC_FILES) { + const spec = JSON.parse(readFileSync(path.join(DOCS_DIR, specFile), 'utf8')) as Json + const ops = collectOperations(specFile, spec) + checkIntegrity(specFile, spec, ops) + + for (const operation of ops) { + const key = `${operation.method.toUpperCase()} ${operation.path}` + documentedKeys.add(key) + const isV2 = operation.path.startsWith('/api/v2/') + if (isV2) checkV2Conventions(operation) + + const entry = registry.get(key) + if (!entry) { + // The core spec's execution/HITL surface predates the contract registry; + // only the v2 surface requires a contract for every documented operation. + if (isV2) fail(specFile, `${key}: documented but no contract exports this route`) + continue + } + checkQueryParams(operation, entry.contract, entry.name) + checkBodyAndResponse(operation, entry.contract, entry.name) + checkExamples(operation, entry.contract, entry.name) + } +} + +for (const [key, { name }] of registry) { + if (!key.includes('/api/v2/')) continue + if (!documentedKeys.has(key)) { + errors.push(`registry: ${name} (${key}) is not documented in any OpenAPI spec`) + } +} + +if (errors.length > 0) { + console.error( + `OpenAPI spec validation failed (${errors.length} issue${errors.length === 1 ? '' : 's'}):` + ) + for (const message of errors) console.error(` - ${message}`) + process.exit(1) +} +console.log( + `OpenAPI spec validation passed: ${SPEC_FILES.length} specs, ${documentedKeys.size} operations, ${registry.size} contracts cross-checked.` +) From 9ee409948cfc297a534dfd5fb9a76cd1a9bb50ae Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 30 Jul 2026 16:55:18 -0700 Subject: [PATCH 05/24] fix(docs): recursive field diff in check:openapi + the deep drift it found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mutation test showed the doc<->contract field diff only compared top-level properties, so a typo inside the { data } envelope passed. The diff now descends through matching object properties and array items (both sides must expose a property set — passthrough contracts and prose-only docs end the descent instead of false-positive), with the Zod JSON-schema root doubling as the $defs context. Deep drift it immediately caught, fixed here: select-column config (options/multiple) missing from every tables column schema, AddColumnBody hand-rolling a third column shape (now composed from ColumnInput, with position/workflowGroupId as the per-op extensions the contracts actually admit), chunking strategyOptions undocumented, and the deployment lifecycle fields (activeDeployment/latestDeploymentAttempt) missing from DeploymentState. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- apps/docs/openapi-v2-knowledge.json | 23 +++++ apps/docs/openapi-v2-tables.json | 111 +++++++++++++++------ apps/docs/openapi-v2-workflows.json | 17 +++- scripts/check-openapi-specs.ts | 149 ++++++++++++++++++++++------ 4 files changed, 239 insertions(+), 61 deletions(-) diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index ef6de06096b..677577e8cc5 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -1001,6 +1001,29 @@ "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" + } + } } } }, diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index acb09fa38df..e461f343b36 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -1648,6 +1648,17 @@ "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." } } }, @@ -1677,6 +1688,21 @@ "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." } } }, @@ -1811,7 +1837,20 @@ "maxItems": 50, "description": "Column definitions. A table must have between 1 and 50 columns.", "items": { - "$ref": "#/components/schemas/ColumnInput" + "allOf": [ + { + "$ref": "#/components/schemas/ColumnInput" + }, + { + "type": "object", + "properties": { + "workflowGroupId": { + "type": "string", + "description": "Advanced: binds the column to a workflow group's output." + } + } + } + ] } } } @@ -1833,38 +1872,22 @@ "description": "The workspace that owns the table." }, "column": { - "type": "object", - "description": "The column definition to add.", - "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": "phone" - }, - "type": { - "type": "string", - "enum": ["string", "number", "boolean", "date", "json"], - "description": "Data type of the column." + "allOf": [ + { + "$ref": "#/components/schemas/ColumnInput" }, - "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." - }, - "position": { - "type": "integer", - "minimum": 0, - "description": "Zero-based insert position in the column order. Appended at the end when omitted." + { + "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." } } }, @@ -1906,6 +1929,17 @@ "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." } } } @@ -2428,6 +2462,21 @@ "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)." + } + } } }, "responses": { diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 79a91ab0eb6..088c75fe278 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -937,7 +937,14 @@ "DeploymentState": { "type": "object", "description": "Base deployment state shared by deploy, undeploy, and rollback results.", - "required": ["id", "isDeployed", "deployedAt", "warnings"], + "required": [ + "id", + "isDeployed", + "deployedAt", + "warnings", + "activeDeployment", + "latestDeploymentAttempt" + ], "properties": { "id": { "type": "string", @@ -961,6 +968,14 @@ "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." } } }, diff --git a/scripts/check-openapi-specs.ts b/scripts/check-openapi-specs.ts index 87cab72e160..3387f058cb9 100644 --- a/scripts/check-openapi-specs.ts +++ b/scripts/check-openapi-specs.ts @@ -155,11 +155,6 @@ function docPropertyNames(schema: unknown, spec: Json): Set | null { return null } -/** Same union logic over the Zod-derived JSON schema (no `$ref`s inside). */ -function zodPropertyNames(schema: unknown): Set | null { - return docPropertyNames(schema, {} as Json) -} - function toJsonSchema(schema: z.ZodType, io: 'input' | 'output'): Json | null { try { return z.toJSONSchema(schema, { io, unrepresentable: 'any' }) as Json @@ -285,6 +280,104 @@ function checkQueryParams(operation: Operation, contract: ContractLike, name: st } } +/** Property subschema lookup, searching `oneOf`/`anyOf`/`allOf` variants. */ +function propertyNode(schema: unknown, root: Json, prop: string): unknown { + const node = deref(schema, root) + if (!node || typeof node !== 'object') return undefined + const record = node as Json + const variants = (record.oneOf ?? record.anyOf ?? record.allOf) as unknown[] | undefined + if (variants) { + for (const variant of variants) { + const found = propertyNode(variant, root, prop) + if (found !== undefined) return found + } + return undefined + } + return (record.properties as Json | undefined)?.[prop] +} + +/** Deref + step through array wrappers so item objects compare directly. */ +function unwrapArrays(node: unknown, root: Json): unknown { + let current = deref(node, root) + for (let i = 0; i < 3; i++) { + const record = current as Json | null + if (record && typeof record === 'object' && record.type === 'array' && record.items) { + current = deref(record.items, root) + } else { + break + } + } + return current +} + +interface DiffContext { + specFile: string + label: string + name: string + where: 'body' | 'response' +} + +/** + * Recursively diffs property-name sets between the Zod-derived JSON schema and + * the documented one, descending through matching object properties and array + * items. Comparison happens only where BOTH sides expose a property set — an + * opaque side (records, `additionalProperties`, prose-only docs) ends the + * descent instead of producing false positives. The Zod root doubles as the + * `$defs` resolution context for recursive schemas. + */ +function diffSchemaFields( + zodNode: unknown, + zodRoot: Json, + docNode: unknown, + docRoot: Json, + ctx: DiffContext, + prefix: string, + depth: number +): void { + if (depth > 4) return + const zodObj = unwrapArrays(zodNode, zodRoot) + const docObj = unwrapArrays(docNode, docRoot) + const zodNames = docPropertyNames(zodObj, zodRoot) + const docNames = docPropertyNames(docObj, docRoot) + if (!zodNames || !docNames) return + const fieldPath = (n: string) => (prefix ? `${prefix}.${n}` : n) + /** + * A `.passthrough()` contract deliberately under-declares its fields, so the + * docs are allowed to document more than the Zod side names. + */ + const extra = (zodObj as Json).additionalProperties + const zodIsPassthrough = + extra === true || (!!extra && typeof extra === 'object' && Object.keys(extra).length === 0) + for (const n of zodNames) { + if (!docNames.has(n)) { + fail( + ctx.specFile, + `${ctx.label}: ${ctx.where} field "${fieldPath(n)}" (${ctx.name}) not documented` + ) + } + } + for (const n of docNames) { + if (!zodNames.has(n) && !zodIsPassthrough) { + fail( + ctx.specFile, + `${ctx.label}: documented ${ctx.where} field "${fieldPath(n)}" does not exist on ${ctx.name}` + ) + } + } + for (const n of zodNames) { + if (!docNames.has(n)) continue + diffSchemaFields( + propertyNode(zodObj, zodRoot, n), + zodRoot, + propertyNode(docObj, docRoot, n), + docRoot, + ctx, + fieldPath(n), + depth + 1 + ) + } +} + function checkBodyAndResponse(operation: Operation, contract: ContractLike, name: string): void { const { specFile, path: p, method, op, spec } = operation const label = `${method.toUpperCase()} ${p}` @@ -293,17 +386,17 @@ function checkBodyAndResponse(operation: Operation, contract: ContractLike, name 'application/json' ] as Json | undefined if (contract.body && docBodySchema?.schema) { - const zodNames = zodPropertyNames(toJsonSchema(contract.body, 'input')) - const docNames = docPropertyNames(docBodySchema.schema, spec) - if (zodNames && docNames) { - for (const n of zodNames) { - if (!docNames.has(n)) fail(specFile, `${label}: body field "${n}" (${name}) not documented`) - } - for (const n of docNames) { - if (!zodNames.has(n)) { - fail(specFile, `${label}: documented body field "${n}" does not exist on ${name}`) - } - } + const zodRoot = toJsonSchema(contract.body, 'input') + if (zodRoot) { + diffSchemaFields( + zodRoot, + zodRoot, + docBodySchema.schema, + spec, + { specFile, label, name, where: 'body' }, + '', + 0 + ) } } @@ -313,19 +406,17 @@ function checkBodyAndResponse(operation: Operation, contract: ContractLike, name const docResponse = successCode ? (deref(responses[successCode], spec) as Json) : undefined const docSchema = ((docResponse?.content as Json)?.['application/json'] as Json)?.schema if (docSchema) { - const zodNames = zodPropertyNames(toJsonSchema(contract.response.schema, 'output')) - const docNames = docPropertyNames(docSchema, spec) - if (zodNames && docNames) { - for (const n of zodNames) { - if (!docNames.has(n)) { - fail(specFile, `${label}: response field "${n}" (${name}) not documented`) - } - } - for (const n of docNames) { - if (!zodNames.has(n)) { - fail(specFile, `${label}: documented response field "${n}" does not exist on ${name}`) - } - } + const zodRoot = toJsonSchema(contract.response.schema, 'output') + if (zodRoot) { + diffSchemaFields( + zodRoot, + zodRoot, + docSchema, + spec, + { specFile, label, name, where: 'response' }, + '', + 0 + ) } } } From f6c9cdb5366eed37995c0a038ac09050f6697aef Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 10:35:14 -0700 Subject: [PATCH 06/24] fix(security): close the triggerType rate-limit bypass on workflow execute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caller-supplied triggerType flowed unchecked into preprocessExecution, whose checkRateLimit default turns OFF for 'manual'/'chat' — so any API-key caller, and any anonymous public-API caller billed to the workspace owner, could execute unthrottled by sending {"triggerType":"manual"} (async runs also skipped the worker-side check via admissionCompleted). External callers may now only send the redundant 'api' value; internal JWT callers ('workflow'/'mcp') are unaffected. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- .../[id]/execute/route.async.test.ts | 56 +++++++++++++++++++ .../app/api/workflows/[id]/execute/route.ts | 18 ++++++ 2 files changed, 74 insertions(+) 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 771e7cda706..3a77981effd 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 @@ -1269,4 +1269,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 f56a89504e6..b122ff694e9 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -730,6 +730,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 (auth.authType === 'api_key') { if (isClientSession) { return NextResponse.json( From 51b0cf15c82aa7400a25a41f181e98c809c3538b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 10:41:35 -0700 Subject: [PATCH 07/24] refactor(execution): extract enqueue/status/cancel into shared libs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepares the v2 execution surface: handleAsyncExecution's queue logic moves to lib/workflows/executor/enqueue-execution.ts (slot/claim semantics encoded in a discriminated outcome, not HTTP statuses), the execution-status read to execution-status.ts, and the order-sensitive cancel machinery to lib/execution/cancel-workflow-execution.ts. The v1 routes re-render identically — their suites pass unmodified. Also: preprocessExecution gains rateLimitCounter ('sync'|'async') and its 429 now carries code RATE_LIMIT_EXCEEDED + retryAfterMs (previously indistinguishable from the concurrency 429 and Retry-After was discarded); and the duplicate cancel contract in contracts/logs.ts is unified on the full 5-value reason enum — its narrower copy made requestJson throw a client ZodError when cancelling a paused HITL run. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- .../app/api/workflows/[id]/execute/route.ts | 154 +-------- .../executions/[executionId]/cancel/route.ts | 268 +--------------- .../[id]/executions/[executionId]/route.ts | 215 +------------ apps/sim/lib/api/contracts/logs.ts | 3 +- apps/sim/lib/api/contracts/workflows.ts | 17 +- .../execution/cancel-workflow-execution.ts | 296 ++++++++++++++++++ apps/sim/lib/execution/preprocessing.ts | 18 +- .../workflows/executor/enqueue-execution.ts | 195 ++++++++++++ .../workflows/executor/execution-status.ts | 220 +++++++++++++ 9 files changed, 774 insertions(+), 612 deletions(-) create mode 100644 apps/sim/lib/execution/cancel-workflow-execution.ts create mode 100644 apps/sim/lib/workflows/executor/enqueue-execution.ts create mode 100644 apps/sim/lib/workflows/executor/execution-status.ts diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index b122ff694e9..735046cbf73 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -20,8 +20,6 @@ import { requireBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' 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, @@ -69,6 +67,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 { @@ -105,7 +104,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, @@ -127,8 +125,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' @@ -338,170 +334,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 } ), 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 d3ff2d2a8a6..6b511174d66 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts @@ -1,105 +1,13 @@ -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 { 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 TraceSpanShape { - blockId?: string - output?: Record - children?: TraceSpanShape[] -} - -interface ExecutionDataShape { - finalOutput?: { error?: string } & Record - error?: { message?: string } | string - completionFailure?: string - traceSpans?: TraceSpanShape[] -} - -function collectBlockOutputs(spans: TraceSpanShape[] | undefined): Map { - const map = new Map() - const visit = (list?: TraceSpanShape[]): void => { - if (!list) return - for (const span of list) { - if (span.blockId && span.output !== undefined && !map.has(span.blockId)) { - map.set(span.blockId, span.output) - } - if (span.children) visit(span.children) - } - } - visit(spans) - return map -} - -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, @@ -115,123 +23,24 @@ 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, - }) - .from(workflowExecutionLogs) - .where( - and( - eq(workflowExecutionLogs.executionId, executionId), - eq(workflowExecutionLogs.workflowId, workflowId) - ) - ) - .limit(1) + const status = await getWorkflowExecutionStatus({ + workflowId, + executionId, + includeOutput, + selectedOutputs, + }) - if (!logRow) { + if (!status) { 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, - } - } - - 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 - - const blockOutputs = - selectedOutputs.length > 0 - ? pickSelectedOutputs(selectedOutputs, collectBlockOutputs(executionData?.traceSpans)) - : null - - 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, - } - 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/lib/api/contracts/logs.ts b/apps/sim/lib/api/contracts/logs.ts index 2e4a2753ae6..4e8071a2f6d 100644 --- a/apps/sim/lib/api/contracts/logs.ts +++ b/apps/sim/lib/api/contracts/logs.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { userFileSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' +import { cancelWorkflowExecutionReasonSchema } from '@/lib/api/contracts/workflows' const comparisonOperatorSchema = z.enum(['=', '>', '<', '>=', '<=', '!=']) @@ -327,7 +328,7 @@ export const cancelWorkflowExecutionResponseSchema = z.object({ durablyRecorded: z.boolean(), locallyAborted: z.boolean(), pausedCancelled: z.boolean(), - reason: z.enum(['recorded', 'redis_unavailable', 'redis_write_failed']), + reason: cancelWorkflowExecutionReasonSchema, }) export type SegmentStats = z.output diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index 60db7b61c7c..b4228468fc7 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -598,6 +598,21 @@ const workflowExecutionStatusQuerySchema = z.object({ ), }) +/** + * Full cancellation-outcome vocabulary — mirrors + * `CancelWorkflowExecutionReason` in `lib/execution/cancel-workflow-execution` + * (contracts stay import-clean of server modules). The paused-HITL path emits + * the two `paused_*` values; a narrower copy of this enum previously lived in + * `contracts/logs.ts` and made the client reject those responses. + */ +export const cancelWorkflowExecutionReasonSchema = z.enum([ + 'recorded', + 'redis_unavailable', + 'redis_write_failed', + 'paused_event_publish_failed', + 'paused_database_cancel_failed', +]) + const cancelWorkflowExecutionResponseSchema = z.object({ success: z.boolean(), executionId: z.string(), @@ -605,7 +620,7 @@ const cancelWorkflowExecutionResponseSchema = z.object({ durablyRecorded: z.boolean(), locallyAborted: z.boolean(), pausedCancelled: z.boolean(), - reason: z.string().optional(), + reason: cancelWorkflowExecutionReasonSchema.optional(), }) const resumeWorkflowExecutionContextResponseSchema = z diff --git a/apps/sim/lib/execution/cancel-workflow-execution.ts b/apps/sim/lib/execution/cancel-workflow-execution.ts new file mode 100644 index 00000000000..8a1b35af297 --- /dev/null +++ b/apps/sim/lib/execution/cancel-workflow-execution.ts @@ -0,0 +1,296 @@ +import { db } from '@sim/db' +import { workflowExecutionLogs } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { sleep } from '@sim/utils/helpers' +import { and, eq } from 'drizzle-orm' +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' + +const logger = createLogger('CancelWorkflowExecution') +const PAUSED_CANCELLATION_DB_ATTEMPTS = 3 +const PAUSED_CANCELLATION_DB_RETRY_MS = 200 + +/** + * Cancellation outcome vocabulary. `recorded`/`redis_unavailable`/ + * `redis_write_failed` come from the Redis record step; the two `paused_*` + * values from the paused-HITL path. + */ +export type CancelWorkflowExecutionReason = + | 'recorded' + | 'redis_unavailable' + | 'redis_write_failed' + | 'paused_event_publish_failed' + | 'paused_database_cancel_failed' + +export interface CancelWorkflowExecutionResult { + success: boolean + executionId: string + redisAvailable: boolean + durablyRecorded: boolean + locallyAborted: boolean + pausedCancelled: boolean + reason?: CancelWorkflowExecutionReason +} + +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 interface CancelWorkflowExecutionInput { + executionId: string + workflowId: string + /** Actor for the analytics event. */ + userId: string + /** Workflow's workspace; feeds the event writer + analytics grouping. */ + workspaceId?: string +} + +/** + * Cancels a workflow execution across the Redis abort record, the in-process + * aborter, and the paused-HITL machinery. The interleaving is order-sensitive + * and shared verbatim by the v1 and v2 cancel routes. Auth is the caller's + * responsibility; this throws on unexpected infrastructure errors. + */ +export async function cancelWorkflowExecution( + input: CancelWorkflowExecutionInput +): Promise { + const { executionId, workflowId, userId, workspaceId } = input + + 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, userId } + ) + pausedCancellationPublishFailed = !pausedCancellationPublished + if (pausedCancellationPublished) { + pausedCancelled = await completePausedCancellationWithRetry(executionId, workflowId) + } + } else { + if (pendingPausedCancellation === 'cancelled') { + pausedCancellationPublished = await ensurePausedCancellationEventPublished( + executionId, + workflowId, + { workspaceId, userId } + ) + pausedCancellationPublishFailed = !pausedCancellationPublished + pausedCancelled = pausedCancellationPublished + } else if (pendingPausedCancellation === 'cancelling') { + pausedCancellationPublished = await ensurePausedCancellationEventPublished( + executionId, + workflowId, + { workspaceId, 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) { + captureServerEvent( + userId, + 'workflow_execution_cancelled', + { workflow_id: workflowId, workspace_id: workspaceId ?? '' }, + workspaceId ? { groups: { workspace: workspaceId } } : undefined + ) + } + + const durablyRecorded = isPausedCancellationPath + ? pausedCancellationPublished + : pausedCancelled || cancellation.durablyRecorded + const reason: CancelWorkflowExecutionReason = pausedCancellationPublishFailed + ? 'paused_event_publish_failed' + : !pausedCancelled && isPausedCancellationPath + ? 'paused_database_cancel_failed' + : pausedCancelled && !pausedCancellationPublished + ? 'paused_event_publish_failed' + : pausedCancelled || isPausedCancellationPath + ? 'recorded' + : cancellation.reason + + return { + success, + executionId, + redisAvailable: + isPausedCancellationPath || pausedCancelled + ? pausedCancellationPublished + : cancellation.reason !== 'redis_unavailable', + durablyRecorded, + locallyAborted, + pausedCancelled, + reason, + } +} diff --git a/apps/sim/lib/execution/preprocessing.ts b/apps/sim/lib/execution/preprocessing.ts index 7b74e1ca1d8..8c1852aad1f 100644 --- a/apps/sim/lib/execution/preprocessing.ts +++ b/apps/sim/lib/execution/preprocessing.ts @@ -56,6 +56,12 @@ export interface PreprocessExecutionOptions { requestId: string checkRateLimit?: boolean + /** + * Which execution token bucket the rate-limit gate debits. Ignored when + * `checkRateLimit` is false. Defaults to `'sync'` — the historical behavior + * for every surface, including async-queued v1 runs. + */ + rateLimitCounter?: 'sync' | 'async' checkDeployment?: boolean skipUsageLimits?: boolean /** @@ -91,6 +97,8 @@ export interface PreprocessExecutionError { statusCode: number code?: string retryable?: boolean + /** Populated on rate-limit denials so callers can emit `Retry-After`. */ + retryAfterMs?: number cause?: Record } @@ -127,6 +135,7 @@ export async function preprocessExecution( reservationId = executionId, requestId, checkRateLimit = triggerType !== 'manual' && triggerType !== 'chat', + rateLimitCounter = 'sync', checkDeployment = triggerType !== 'manual', skipUsageLimits = false, skipConcurrencyReservation = false, @@ -569,7 +578,7 @@ export async function preprocessExecution( actorUserId, actorSubscription, triggerType, - false + rateLimitCounter === 'async' ) if (!info.allowed) { @@ -585,6 +594,13 @@ export async function preprocessExecution( error: { message: `Rate limit exceeded. Please try again later.`, statusCode: 429, + /** + * Distinguishes quota exhaustion from the concurrency-slot 429 + * (`EXECUTION_CONCURRENCY_LIMIT`, retryable in seconds) — the two + * need different caller behavior. + */ + code: 'RATE_LIMIT_EXCEEDED', + retryAfterMs: info.retryAfterMs ?? Math.max(0, info.resetAt.getTime() - Date.now()), }, }, recordError: { diff --git a/apps/sim/lib/workflows/executor/enqueue-execution.ts b/apps/sim/lib/workflows/executor/enqueue-execution.ts new file mode 100644 index 00000000000..949c98be1dc --- /dev/null +++ b/apps/sim/lib/workflows/executor/enqueue-execution.ts @@ -0,0 +1,195 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' +import { isAsyncJobEnqueueError } from '@/lib/core/async-jobs/types' +import { executeWorkflowJob, type WorkflowExecutionPayload } from '@/background/workflow-execution' +import type { CoreTriggerType } from '@/stores/logs/filters/types' + +const logger = createLogger('WorkflowEnqueueExecution') + +const ASYNC_ENQUEUE_ATTEMPTS = 2 +export const WORKFLOW_EXECUTION_JOB_ID_PREFIX = 'workflow-execution:' + +export interface EnqueueWorkflowExecutionParams { + requestId: string + workflowId: string + userId: string + billingAttribution: BillingAttributionSnapshot + workspaceId: string + input: unknown + triggerType: CoreTriggerType + executionId: string + callChain?: string[] +} + +/** + * Outcome of an async enqueue attempt. Slot/claim semantics are encoded here, + * not in HTTP statuses (which are ambiguous — a 503 can mean five different + * things on the execute surface): + * - `queued`: the job holds the execution slot (`admissionCompleted: true`) and + * the execution-id claim must be RETAINED — the worker writes the durable log + * row under that id. + * - `rejected`: the queue definitively refused; the slot was released here and + * the claim must be released by the caller. + * - `ambiguous`: acceptance is unknown — a job may exist, so NEITHER the slot + * nor the claim may be released (releasing would double-free under a live + * job); the slot leaks to TTL only if the job genuinely never landed. + */ +export type EnqueueWorkflowExecutionResult = + | { outcome: 'queued'; jobId: string; executionId: string; retainExecutionClaim: true } + | { outcome: 'rejected'; executionId: string; retainExecutionClaim: false } + | { outcome: 'ambiguous'; executionId: string; retainExecutionClaim: true } + +/** + * Enqueues an async workflow execution. The caller must have already admitted, + * claimed the execution id, and reserved the execution slot (via + * `preprocessExecution`); the enqueued job inherits that reservation. + * Shared by the v1 and v2 execute routes so retry, acceptance classification, + * and inline-execution dispatch can never drift between surfaces. + */ +export async function enqueueWorkflowExecution( + params: EnqueueWorkflowExecutionParams +): 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, + } + + let jobQueue: Awaited> + try { + jobQueue = await getJobQueue() + } catch (error) { + asyncLogger.error('Failed to initialize async execution queue', { + error: toError(error).message, + }) + await releaseExecutionSlot(executionId) + return { outcome: 'rejected', executionId, 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 { outcome: 'rejected', executionId, retainExecutionClaim: false } + } + + return { outcome: 'ambiguous', 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 { outcome: 'queued', jobId, executionId, retainExecutionClaim: true } +} diff --git a/apps/sim/lib/workflows/executor/execution-status.ts b/apps/sim/lib/workflows/executor/execution-status.ts new file mode 100644 index 00000000000..5c73436a688 --- /dev/null +++ b/apps/sim/lib/workflows/executor/execution-status.ts @@ -0,0 +1,220 @@ +import { db } from '@sim/db' +import { pausedExecutions, workflowExecutionLogs } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' +import type { WorkflowExecutionStatusResponse } from '@/lib/api/contracts/workflows' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { getAutomaticResumeWaitingMetadata } from '@/lib/workflows/executor/paused-execution-metadata' +import type { PausePoint } from '@/executor/types' + +/** + * Reads a single execution's status resource — the log row, the paused-state + * overlay, and (when requested) materialized outputs. Extracted so the v1 and + * v2 status routes render the identical resource from one read path. + * Auth is the caller's responsibility. Returns `null` when no log row exists. + */ + +type LogStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' + +interface TraceSpanShape { + blockId?: string + output?: Record + children?: TraceSpanShape[] +} + +interface ExecutionDataShape { + finalOutput?: { error?: string } & Record + error?: { message?: string } | string + completionFailure?: string + traceSpans?: TraceSpanShape[] +} + +function collectBlockOutputs(spans: TraceSpanShape[] | undefined): Map { + const map = new Map() + const visit = (list?: TraceSpanShape[]): void => { + if (!list) return + for (const span of list) { + if (span.blockId && span.output !== undefined && !map.has(span.blockId)) { + map.set(span.blockId, span.output) + } + if (span.children) visit(span.children) + } + } + visit(spans) + return map +} + +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 interface GetWorkflowExecutionStatusInput { + workflowId: string + executionId: string + includeOutput: boolean + selectedOutputs: string[] +} + +export async function getWorkflowExecutionStatus( + input: GetWorkflowExecutionStatusInput +): Promise { + const { workflowId, executionId, includeOutput, selectedOutputs } = input + + 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, + }) + .from(workflowExecutionLogs) + .where( + and( + eq(workflowExecutionLogs.executionId, executionId), + eq(workflowExecutionLogs.workflowId, workflowId) + ) + ) + .limit(1) + + if (!logRow) return null + + 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, + } + } + + 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 + + const blockOutputs = + selectedOutputs.length > 0 + ? pickSelectedOutputs(selectedOutputs, collectBlockOutputs(executionData?.traceSpans)) + : null + + return { + executionId: logRow.executionId, + // Column is `set null` on workflow delete; the caller's param is the fallback. + 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, + } +} From 4320fed5fee62a759cb3dbbcb31347650e5a9435 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 10:48:28 -0700 Subject: [PATCH 08/24] feat(execution): callable execution service + structured error classifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit executeWorkflowService composes the same libs the v1 route holds inline (call-chain guard, execution-id claim, LoggingSession, preprocessing, deployed-state load + file-field processing, timeout-bound executeWorkflowCore, output hydration/compaction) for the deployed-state caller class — the seam the v2 execute route and in-process internal callers share, making the HTTP endpoint syntactic sugar. classifyExecutionError stops discarding the block context that buildBlockExecutionError already attaches at throw sites: failed runs now yield {message, code, blockId, blockName, blockType} with a stable append-only code enum (TIMEOUT/CANCELLED/USAGE_LIMIT_EXCEEDED/ INVALID_INPUT/BLOCK_EXECUTION_FAILED/CHILD_WORKFLOW_FAILED/ OUTPUT_TOO_LARGE/EXECUTION_FAILED), so callers route on error class instead of substring-matching messages — the single place raw errors are interpreted. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- .../executor/utils/errors.classify.test.ts | 149 +++++ apps/sim/executor/utils/errors.ts | 117 ++++ .../lib/workflows/executor/execute-service.ts | 594 ++++++++++++++++++ 3 files changed, 860 insertions(+) create mode 100644 apps/sim/executor/utils/errors.classify.test.ts create mode 100644 apps/sim/lib/workflows/executor/execute-service.ts diff --git a/apps/sim/executor/utils/errors.classify.test.ts b/apps/sim/executor/utils/errors.classify.test.ts new file mode 100644 index 00000000000..3051cf6a52c --- /dev/null +++ b/apps/sim/executor/utils/errors.classify.test.ts @@ -0,0 +1,149 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { ExecutionResult } from '@/executor/types' +import { + attachExecutionResult, + buildBlockExecutionError, + classifyExecutionError, +} from '@/executor/utils/errors' + +function failedResult(partial?: Partial): ExecutionResult { + return { success: false, output: {}, ...partial } +} + +describe('classifyExecutionError', () => { + it('reads block context from the fields buildBlockExecutionError attaches and strips the name prefix', () => { + const error = buildBlockExecutionError({ + block: { id: 'block-1', metadata: { name: 'Send Email', id: 'gmail' } } as never, + error: new Error('Invalid credentials'), + }) + + const classified = classifyExecutionError(error) + + expect(classified).toMatchObject({ + message: 'Invalid credentials', + code: 'BLOCK_EXECUTION_FAILED', + blockId: 'block-1', + blockName: 'Send Email', + blockType: 'gmail', + }) + }) + + it('falls back to the last failed, un-handled block log', () => { + const result = failedResult({ + error: 'Agent: model refused', + logs: [ + { + blockId: 'b-ok', + blockName: 'First', + blockType: 'function', + success: true, + startedAt: '', + endedAt: '', + durationMs: 1, + }, + { + blockId: 'b-handled', + blockName: 'Handled', + blockType: 'api', + success: false, + errorHandled: true, + error: 'handled upstream', + startedAt: '', + endedAt: '', + durationMs: 1, + }, + { + blockId: 'b-fail', + blockName: 'Agent', + blockType: 'agent', + success: false, + error: 'model refused', + startedAt: '', + endedAt: '', + durationMs: 1, + }, + ], + }) + + const classified = classifyExecutionError(new Error('Agent: model refused'), result) + + expect(classified).toMatchObject({ + message: 'model refused', + code: 'BLOCK_EXECUTION_FAILED', + blockId: 'b-fail', + blockName: 'Agent', + blockType: 'agent', + }) + }) + + it('classifies child-workflow failures so parents can route on error class', () => { + const result = failedResult({ + logs: [ + { + blockId: 'wf-block', + blockName: 'Enrich Lead', + blockType: 'workflow_input', + success: false, + error: 'Child workflow failed', + startedAt: '', + endedAt: '', + durationMs: 1, + }, + ], + }) + + expect(classifyExecutionError(new Error('Child workflow failed'), result).code).toBe( + 'CHILD_WORKFLOW_FAILED' + ) + }) + + it('maps the attached 4xx statusCode families', () => { + const timeoutError = new Error('Execution exceeded the time limit') + Object.assign(timeoutError, { statusCode: 408 }) + expect(classifyExecutionError(timeoutError).code).toBe('TIMEOUT') + + const usageError = new Error('Usage limit exceeded for this billing period') + Object.assign(usageError, { statusCode: 402 }) + expect(classifyExecutionError(usageError).code).toBe('USAGE_LIMIT_EXCEEDED') + }) + + it('uses the attached executionResult when none is passed explicitly', () => { + const error = new Error('Slack: channel not found') + attachExecutionResult( + error, + failedResult({ + logs: [ + { + blockId: 'slack-1', + blockName: 'Slack', + blockType: 'slack', + success: false, + error: 'channel not found', + startedAt: '', + endedAt: '', + durationMs: 1, + }, + ], + }) + ) + + expect(classifyExecutionError(error)).toMatchObject({ + code: 'BLOCK_EXECUTION_FAILED', + blockId: 'slack-1', + message: 'channel not found', + }) + }) + + it('falls back to EXECUTION_FAILED with the raw message when nothing is classifiable', () => { + expect(classifyExecutionError(new Error('something odd'))).toEqual({ + message: 'something odd', + code: 'EXECUTION_FAILED', + blockId: undefined, + blockName: undefined, + blockType: undefined, + }) + }) +}) diff --git a/apps/sim/executor/utils/errors.ts b/apps/sim/executor/utils/errors.ts index edcff8342ea..4b317377308 100644 --- a/apps/sim/executor/utils/errors.ts +++ b/apps/sim/executor/utils/errors.ts @@ -117,3 +117,120 @@ export function normalizeError(error: unknown): string { } return String(error) } + +/** + * Stable, append-only error classes for failed workflow executions. Callers + * (v2 API consumers, parent workflows, MCP clients) route on these instead of + * substring-matching messages; this module is the single place raw errors are + * interpreted, so the executor can later attach codes natively at throw sites + * without a wire change. + */ +export type WorkflowExecutionErrorCode = + | 'TIMEOUT' + | 'CANCELLED' + | 'USAGE_LIMIT_EXCEEDED' + | 'INVALID_INPUT' + | 'BLOCK_EXECUTION_FAILED' + | 'CHILD_WORKFLOW_FAILED' + | 'OUTPUT_TOO_LARGE' + | 'EXECUTION_FAILED' + +export interface StructuredExecutionError { + message: string + code: WorkflowExecutionErrorCode + blockId?: string + blockName?: string + blockType?: string +} + +interface AttachedBlockContext { + blockId?: unknown + blockName?: unknown + blockType?: unknown +} + +function readAttachedBlockContext(error: unknown): { + blockId?: string + blockName?: string + blockType?: string +} { + if (!(error instanceof Error)) return {} + const attached = error as unknown as AttachedBlockContext + return { + blockId: typeof attached.blockId === 'string' ? attached.blockId : undefined, + blockName: typeof attached.blockName === 'string' ? attached.blockName : undefined, + blockType: typeof attached.blockType === 'string' ? attached.blockType : undefined, + } +} + +function lastFailedBlockLog(result: ExecutionResult | undefined): { + blockId?: string + blockName?: string + blockType?: string + error?: string +} { + const logs = result?.logs + if (!logs?.length) return {} + for (let i = logs.length - 1; i >= 0; i--) { + const log = logs[i] + if (!log.success && log.errorHandled !== true) { + return { + blockId: log.blockId, + blockName: log.blockName, + blockType: log.blockType, + error: log.error, + } + } + } + return {} +} + +const CHILD_WORKFLOW_BLOCK_TYPES = new Set(['workflow', 'workflow_input']) + +/** + * Classifies a failed execution into {@link StructuredExecutionError}. + * Block context comes from the fields {@link buildBlockExecutionError} already + * attaches at the throw site, falling back to the last failed, un-handled + * `BlockLog`. The message drops the historical `"BlockName: "` prefix once + * `blockName` is carried as its own field. + */ +export function classifyExecutionError( + error: unknown, + result?: ExecutionResult +): StructuredExecutionError { + const executionResult = result ?? (hasExecutionResult(error) ? error.executionResult : undefined) + const attached = readAttachedBlockContext(error) + const fromLog = lastFailedBlockLog(executionResult) + const blockId = attached.blockId ?? fromLog.blockId + const blockName = attached.blockName ?? fromLog.blockName + const blockType = attached.blockType ?? fromLog.blockType + + let message = + (error instanceof Error ? error.message : undefined) ?? + executionResult?.error ?? + fromLog.error ?? + 'Execution failed' + if (blockName && message.startsWith(`${blockName}: `)) { + message = message.slice(blockName.length + 2) + } + + const statusCode = error instanceof Error ? getExecutionErrorStatus(error) : undefined + let code: WorkflowExecutionErrorCode + if (statusCode === 408 || /\btimed? ?out\b/i.test(message)) { + code = 'TIMEOUT' + } else if (statusCode === 402 || /usage limit/i.test(message)) { + code = 'USAGE_LIMIT_EXCEEDED' + } else if (executionResult?.status === 'cancelled' || /\bcancelled\b/i.test(message)) { + code = 'CANCELLED' + } else if (/invalid input format/i.test(message)) { + code = 'INVALID_INPUT' + } else if (blockType && CHILD_WORKFLOW_BLOCK_TYPES.has(blockType)) { + code = 'CHILD_WORKFLOW_FAILED' + } else if (blockId) { + code = 'BLOCK_EXECUTION_FAILED' + } else { + code = 'EXECUTION_FAILED' + } + + return { message, code, blockId, blockName, blockType } +} diff --git a/apps/sim/lib/workflows/executor/execute-service.ts b/apps/sim/lib/workflows/executor/execute-service.ts new file mode 100644 index 00000000000..db45a761d32 --- /dev/null +++ b/apps/sim/lib/workflows/executor/execute-service.ts @@ -0,0 +1,594 @@ +import type { workflow as workflowTable } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { createTimeoutAbortController, getTimeoutErrorMessage } from '@/lib/core/execution-limits' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { validateCallChain } from '@/lib/execution/call-chain' +import { processInputFileFields } from '@/lib/execution/files' +import { containsLargeValueRef } from '@/lib/execution/payloads/large-value-ref' +import { compactExecutionPayload } from '@/lib/execution/payloads/serializer' +import { preprocessExecution } from '@/lib/execution/preprocessing' +import { LoggingSession } from '@/lib/logs/execution/logging-session' +import { MAX_MCP_WORKFLOW_RESPONSE_BYTES } from '@/lib/mcp/constants' +import { hydrateUserFilesWithBase64 } from '@/lib/uploads/utils/user-file-base64.server' +import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/operations' +import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' +import { + claimExecutionId, + type ExecutionIdClaim, + hasDurableExecutionOwner, + releaseExecutionIdClaim, +} from '@/lib/workflows/executor/execution-id-claim' +import { handlePostExecutionPauseState } from '@/lib/workflows/executor/pause-persistence' +import { + loadDeployedWorkflowState, + loadWorkflowDeploymentVersionState, +} from '@/lib/workflows/persistence/utils' +import { workflowHasResponseBlock } from '@/lib/workflows/utils' +import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay' +import { ExecutionSnapshot } from '@/executor/execution/snapshot' +import type { ExecutionMetadata } from '@/executor/execution/types' +import type { NormalizedBlockOutput } from '@/executor/types' +import { + classifyExecutionError, + hasExecutionResult, + type StructuredExecutionError, +} from '@/executor/utils/errors' +import { Serializer } from '@/serializer' +import type { CoreTriggerType } from '@/stores/logs/filters/types' + +const logger = createLogger('WorkflowExecuteService') + +const EXECUTION_ID_CLAIM_ATTEMPTS = 3 + +type WorkflowRecord = typeof workflowTable.$inferSelect + +/** + * Sync workflow execution as a callable service — the orchestration the v1 + * execute route holds inline (call-chain guard, execution-id claim, + * LoggingSession, preprocessing/billing, deployed-state load, file-field + * processing, timeout-bound core execution, output compaction), composed from + * the same libs, for the caller class that runs DEPLOYED state with no draft or + * override controls: the v2 execute route and in-process internal callers + * (MCP bridge). The HTTP endpoints are syntactic sugar over this function. + */ +export interface ExecuteWorkflowServiceParams { + workflowId: string + /** Authenticated user driving actor resolution in preprocessing. */ + userId: string + input: unknown + /** Server-derived caller class — never a wire field. */ + triggerType: CoreTriggerType + requestId: string + /** Caller-supplied idempotent execution id; a reused id fails with `conflict`. */ + executionId?: string + callChain?: string[] + useAuthenticatedUserAsActor?: boolean + /** Pre-fetched workflow row (already authorized by the caller). */ + workflowRecord?: WorkflowRecord + upstreamBillingAttribution?: BillingAttributionSnapshot + /** Pin execution to a specific deployment version (MCP bridge). */ + deploymentVersionId?: string + includeFileBase64?: boolean + base64MaxBytes?: number + selectedOutputs?: string[] + /** MCP behavior: 413-style failure instead of large-value refs in output. */ + rejectLargeInlineOutput?: boolean + /** Which rate-limit bucket preprocessing debits. */ + rateLimitCounter?: 'sync' | 'async' + /** Outer request signal; aborting cancels the run (client disconnect). */ + abortSignal?: AbortSignal +} + +export interface ExecuteWorkflowServiceFailure { + kind: 'precheck' | 'conflict' | 'input' | 'aborted' | 'output_too_large' | 'infra' + message: string + statusCode: number + code?: string + retryAfterMs?: number + /** Present when an execution identity was already minted (conflict/ambiguous cases). */ + executionId?: string +} + +export interface ExecuteWorkflowServiceRun { + ok: true + executionId: string + workflowId: string + status: 'completed' | 'failed' | 'paused' | 'cancelled' + /** Why a `cancelled`/`failed` terminal state occurred, when signal-driven. */ + aborted: 'client' | 'timeout' | null + output: NormalizedBlockOutput | undefined + error: StructuredExecutionError | null + hasResponseBlock: boolean + startedAt?: string + endedAt?: string + durationMs?: number +} + +export type ExecuteWorkflowServiceResult = + | ExecuteWorkflowServiceRun + | { ok: false; failure: ExecuteWorkflowServiceFailure } + +function failure(f: ExecuteWorkflowServiceFailure): ExecuteWorkflowServiceResult { + return { ok: false, failure: f } +} + +async function compactServiceOutput( + value: T, + context: { + workspaceId: string + workflowId: string + executionId: string + userId: string + rejectLargeInlineOutput: boolean + } +): Promise { + const compacted = await compactExecutionPayload(value, { + workspaceId: context.workspaceId, + workflowId: context.workflowId, + executionId: context.executionId, + userId: context.userId, + preserveUserFileBase64: true, + preserveRoot: !context.rejectLargeInlineOutput, + rejectLargeValues: context.rejectLargeInlineOutput, + rejectLargeValueLabel: 'Workflow execution response', + thresholdBytes: context.rejectLargeInlineOutput ? MAX_MCP_WORKFLOW_RESPONSE_BYTES : undefined, + requireDurable: true, + }) + + if (context.rejectLargeInlineOutput && containsLargeValueRef(compacted)) { + throw new PayloadSizeLimitError({ + label: 'Workflow execution response', + maxBytes: MAX_MCP_WORKFLOW_RESPONSE_BYTES, + observedBytes: MAX_MCP_WORKFLOW_RESPONSE_BYTES + 1, + }) + } + + return compacted +} + +export async function executeWorkflowService( + params: ExecuteWorkflowServiceParams +): Promise { + const { + workflowId, + userId, + input, + triggerType, + requestId, + callChain, + useAuthenticatedUserAsActor = false, + workflowRecord, + upstreamBillingAttribution, + deploymentVersionId, + includeFileBase64 = true, + base64MaxBytes, + selectedOutputs = [], + rejectLargeInlineOutput = false, + rateLimitCounter = 'sync', + abortSignal, + } = params + + let reqLogger = logger.withMetadata({ requestId, workflowId, userId }) + + if (callChain) { + const chainError = validateCallChain(callChain) + if (chainError) { + return failure({ kind: 'precheck', message: chainError, statusCode: 409 }) + } + } + + const callerProvidedExecutionId = Boolean(params.executionId) + let executionId = params.executionId ?? generateId() + reqLogger = reqLogger.withMetadata({ executionId }) + + let executionIdClaim: ExecutionIdClaim | null = null + let executionIdClaimCommitted = false + + try { + try { + for (let attempt = 1; attempt <= EXECUTION_ID_CLAIM_ATTEMPTS; attempt++) { + executionIdClaim = await claimExecutionId(executionId) + if (executionIdClaim || callerProvidedExecutionId) break + if (attempt < EXECUTION_ID_CLAIM_ATTEMPTS) { + executionId = generateId() + reqLogger = reqLogger.withMetadata({ executionId }) + } + } + } catch (error) { + reqLogger.error('Failed to claim workflow execution ID', { + error: getErrorMessage(error), + }) + return failure({ + kind: 'infra', + message: 'Workflow execution identity is temporarily unavailable', + statusCode: 503, + }) + } + + if (!executionIdClaim) { + if (callerProvidedExecutionId) { + return failure({ + kind: 'conflict', + message: 'Execution ID has already been used', + statusCode: 409, + code: 'EXECUTION_ID_CONFLICT', + executionId, + }) + } + reqLogger.error('Failed to allocate a unique server execution ID') + return failure({ + kind: 'infra', + message: 'Unable to allocate workflow execution identity', + statusCode: 503, + }) + } + + const loggingSession = new LoggingSession(workflowId, executionId, triggerType, requestId) + + const preprocessResult = await preprocessExecution({ + workflowId, + userId, + triggerType, + executionId, + requestId, + checkDeployment: true, + rateLimitCounter, + loggingSession, + useAuthenticatedUserAsActor, + workflowRecord, + billingAttribution: upstreamBillingAttribution, + }) + + if (!preprocessResult.success) { + const preprocessError = preprocessResult.error + return failure({ + kind: 'precheck', + message: preprocessError.message, + statusCode: preprocessError.statusCode, + code: preprocessError.code, + retryAfterMs: preprocessError.retryAfterMs, + }) + } + + // Preprocessing reserved an admission slot (released when the LoggingSession + // finalizes). Any path that exits before execution starts must release it + // here, or the slot leaks until its TTL and wrongly throttles later runs. + if (abortSignal?.aborted) { + await releaseExecutionSlot(executionId) + return failure({ kind: 'aborted', message: 'Client cancelled request', statusCode: 499 }) + } + + const actorUserId = preprocessResult.actorUserId + const workflow = preprocessResult.workflowRecord + const billingAttribution = preprocessResult.billingAttribution + const workspaceId = workflow.workspaceId + if (!workspaceId) { + await releaseExecutionSlot(executionId) + return failure({ + kind: 'infra', + message: 'Invalid execution context returned by preprocessing', + statusCode: 500, + }) + } + reqLogger = reqLogger.withMetadata({ workspaceId, userId: actorUserId }) + + let processedInput = input + let workflowVariables: Record = {} + try { + const workflowData = deploymentVersionId + ? await loadWorkflowDeploymentVersionState(workflowId, deploymentVersionId, workspaceId) + : await loadDeployedWorkflowState(workflowId, workspaceId) + + if (abortSignal?.aborted) { + await releaseExecutionSlot(executionId) + return failure({ kind: 'aborted', message: 'Client cancelled request', statusCode: 499 }) + } + + if (workflowData) { + workflowVariables = + ('variables' in workflowData + ? (workflowData.variables as Record | undefined) + : undefined) ?? + (workflow.variables as Record | null) ?? + {} + + // Custom blocks resolve only inside the org overlay; wrap this pre-execution + // serialize (used for input file-field discovery) the same way the core does. + const customBlockRows = await getCustomBlockRowsForWorkspace(workspaceId) + const serializedWorkflow = await withCustomBlockOverlay(customBlockRows, async () => + new Serializer().serializeWorkflow( + workflowData.blocks, + workflowData.edges, + workflowData.loops || {}, + workflowData.parallels || {}, + false + ) + ) + + processedInput = await processInputFileFields( + input, + serializedWorkflow.blocks, + { workspaceId, workflowId, executionId }, + requestId, + actorUserId + ) + } else { + workflowVariables = (workflow.variables as Record | null) ?? {} + } + } catch (fileError) { + reqLogger.error('Failed to process input file fields', { error: fileError }) + executionIdClaimCommitted = await loggingSession.safeStart({ + userId: actorUserId, + billingAttribution, + workspaceId, + variables: {}, + }) + await loggingSession.safeCompleteWithError({ + error: { + message: `File processing failed: ${getErrorMessage(fileError, 'Unable to process input files')}`, + stackTrace: fileError instanceof Error ? fileError.stack : undefined, + }, + traceSpans: [], + }) + return failure({ + kind: 'input', + message: `File processing failed: ${getErrorMessage(fileError, 'Unable to process input files')}`, + statusCode: 400, + }) + } + + const metadata: ExecutionMetadata = { + requestId, + executionId, + workflowId, + workspaceId, + userId: actorUserId, + billingAttribution, + workflowUserId: workflow.userId, + triggerType, + useDraftState: false, + startTime: new Date().toISOString(), + isClientSession: false, + enforceCredentialAccess: useAuthenticatedUserAsActor, + largeValueExecutionIds: [executionId], + largeValueKeys: [], + fileKeys: [], + allowLargeValueWorkflowScope: false, + callChain, + executionMode: 'sync', + } + + const timeoutController = createTimeoutAbortController(preprocessResult.executionTimeout?.sync) + let requestAborted = false + const abortFromRequest = () => { + requestAborted = true + timeoutController.abort() + } + if (abortSignal?.aborted) { + abortFromRequest() + } else { + abortSignal?.addEventListener('abort', abortFromRequest) + } + const isRequestAborted = () => requestAborted || Boolean(abortSignal?.aborted) + + const compactionContext = { + workspaceId, + workflowId, + executionId, + userId: actorUserId, + rejectLargeInlineOutput, + } + + try { + const snapshot = new ExecutionSnapshot( + metadata, + workflow, + processedInput, + workflowVariables, + selectedOutputs + ) + + const result = await executeWorkflowCore({ + snapshot, + callbacks: {}, + loggingSession, + includeFileBase64, + base64MaxBytes, + abortSignal: timeoutController.signal, + }) + + await handlePostExecutionPauseState({ result, workflowId, executionId, loggingSession }) + + if (result.status === 'cancelled' && isRequestAborted() && !timeoutController.isTimedOut()) { + reqLogger.info('Execution cancelled by client disconnect') + await loggingSession.markAsFailed('Client cancelled request') + return { + ok: true, + executionId, + workflowId, + status: 'cancelled', + aborted: 'client', + output: undefined, + error: { message: 'Client cancelled request', code: 'CANCELLED' }, + hasResponseBlock: false, + } + } + + if ( + result.status === 'cancelled' && + timeoutController.isTimedOut() && + timeoutController.timeoutMs + ) { + const timeoutErrorMessage = getTimeoutErrorMessage(null, timeoutController.timeoutMs) + reqLogger.info('Execution timed out', { timeoutMs: timeoutController.timeoutMs }) + await loggingSession.markAsFailed(timeoutErrorMessage) + const compactTimeoutOutput = await compactServiceOutput(result.output, compactionContext) + return { + ok: true, + executionId, + workflowId, + status: 'failed', + aborted: 'timeout', + output: compactTimeoutOutput, + error: { message: timeoutErrorMessage, code: 'TIMEOUT' }, + hasResponseBlock: false, + startedAt: result.metadata?.startTime, + endedAt: result.metadata?.endTime, + durationMs: result.metadata?.duration, + } + } + + const outputWithBase64 = + includeFileBase64 && !rejectLargeInlineOutput + ? ((await hydrateUserFilesWithBase64(result.output, { + requestId, + workspaceId, + workflowId, + executionId, + largeValueExecutionIds: [executionId], + largeValueKeys: result.metadata?.largeValueKeys ?? [], + fileKeys: result.metadata?.fileKeys ?? [], + allowLargeValueWorkflowScope: false, + userId: actorUserId, + maxBytes: base64MaxBytes, + preserveLargeValueMetadata: true, + })) as NormalizedBlockOutput) + : result.output + + const compactOutput = await compactServiceOutput(outputWithBase64, compactionContext) + + const status: ExecuteWorkflowServiceRun['status'] = + result.status === 'paused' + ? 'paused' + : result.status === 'cancelled' + ? 'cancelled' + : result.success + ? 'completed' + : 'failed' + + return { + ok: true, + executionId, + workflowId, + status, + aborted: null, + output: compactOutput, + error: + status === 'failed' || (status === 'cancelled' && result.error) + ? classifyExecutionError(result.error ? new Error(result.error) : undefined, result) + : null, + hasResponseBlock: workflowHasResponseBlock(result), + startedAt: result.metadata?.startTime, + endedAt: result.metadata?.endTime, + durationMs: result.metadata?.duration, + } + } catch (error: unknown) { + const errorMessage = getErrorMessage(error, 'Unknown error') + + if (isRequestAborted() && !timeoutController.isTimedOut()) { + reqLogger.info('Execution aborted after client disconnect') + return { + ok: true, + executionId, + workflowId, + status: 'cancelled', + aborted: 'client', + output: undefined, + error: { message: 'Client cancelled request', code: 'CANCELLED' }, + hasResponseBlock: false, + } + } + + if ( + error instanceof PayloadSizeLimitError && + rejectLargeInlineOutput && + error.label === 'Workflow execution response' + ) { + return failure({ + kind: 'output_too_large', + message: 'Workflow execution response exceeds maximum size', + statusCode: 413, + code: 'workflow_response_too_large', + executionId, + }) + } + + reqLogger.error(`Execution failed: ${errorMessage}`) + + const executionResult = hasExecutionResult(error) ? error.executionResult : undefined + let compactErrorOutput: NormalizedBlockOutput | undefined + if (executionResult && Object.hasOwn(executionResult, 'output')) { + try { + compactErrorOutput = await compactServiceOutput(executionResult.output, compactionContext) + } catch (compactError) { + if ( + compactError instanceof PayloadSizeLimitError && + rejectLargeInlineOutput && + compactError.label === 'Workflow execution response' + ) { + return failure({ + kind: 'output_too_large', + message: 'Workflow execution response exceeds maximum size', + statusCode: 413, + code: 'workflow_response_too_large', + executionId, + }) + } + throw compactError + } + } + + return { + ok: true, + executionId, + workflowId, + status: 'failed', + aborted: null, + output: compactErrorOutput, + error: classifyExecutionError(error, executionResult), + hasResponseBlock: false, + startedAt: executionResult?.metadata?.startTime, + endedAt: executionResult?.metadata?.endTime, + durationMs: executionResult?.metadata?.duration, + } + } finally { + abortSignal?.removeEventListener('abort', abortFromRequest) + timeoutController.cleanup() + } + } catch (error) { + reqLogger.error('Failed to start workflow execution', { error: toError(error).message }) + if (executionId) await releaseExecutionSlot(executionId) + return failure({ + kind: 'infra', + message: toError(error).message || 'Failed to start workflow execution', + statusCode: 500, + }) + } finally { + if (executionIdClaim && !executionIdClaimCommitted) { + try { + executionIdClaimCommitted = await hasDurableExecutionOwner(executionId) + } catch (error) { + executionIdClaimCommitted = true + reqLogger.warn('Unable to verify execution ID ownership; retaining claim', { + error: toError(error).message, + executionId, + }) + } + } + + if (executionIdClaim && !executionIdClaimCommitted) { + try { + await releaseExecutionIdClaim(executionIdClaim) + } catch (error) { + reqLogger.warn('Failed to release pre-start execution ID claim', { + error: toError(error).message, + executionId, + }) + } + } + } +} From 9c364004851815e6fb89056f0ef51524cd9d7dd4 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 10:55:26 -0700 Subject: [PATCH 09/24] feat(api): POST /api/v2/workflows/[id]/execute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin route over executeWorkflowService: X-API-Key or anonymous public-API auth (sync/stream only for anonymous), strict body with body-flag async (no mode headers on v2), SSE passthrough for stream, and the execution resource response — executionId always present, in-band run failures are status:'failed' with the structured {message, code, blockId, blockName, blockType} error, sync timeout is status:'failed' + TIMEOUT instead of v1's 408, and a Response block's payload stays inside output (authors never control response status/headers on this origin). Async debits the async bucket and the 202 statusUrl points at the v2 executions resource. Adds CLIENT_CLOSED_REQUEST/SERVICE_UNAVAILABLE to the v2 error codes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- apps/sim/app/api/v2/lib/response.ts | 4 + .../v2/workflows/[id]/execute/route.test.ts | 388 ++++++++++++++++++ .../api/v2/workflows/[id]/execute/route.ts | 286 +++++++++++++ apps/sim/lib/api/contracts/v2/workflows.ts | 87 ++++ .../lib/workflows/executor/execute-service.ts | 215 +++++++++- 5 files changed, 979 insertions(+), 1 deletion(-) create mode 100644 apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/execute/route.ts diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index 85b117c1022..bd326d1218d 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -22,7 +22,9 @@ export type V2ErrorCode = | 'USAGE_LIMIT_EXCEEDED' | 'LOCKED' | 'RATE_LIMITED' + | 'CLIENT_CLOSED_REQUEST' | 'INTERNAL_ERROR' + | 'SERVICE_UNAVAILABLE' const STATUS_BY_CODE: Record = { BAD_REQUEST: 400, @@ -35,7 +37,9 @@ const STATUS_BY_CODE: Record = { UNSUPPORTED_MEDIA_TYPE: 415, LOCKED: 423, RATE_LIMITED: 429, + CLIENT_CLOSED_REQUEST: 499, INTERNAL_ERROR: 500, + SERVICE_UNAVAILABLE: 503, } /** 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..4f70c4942f5 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts @@ -0,0 +1,388 @@ +/** + * @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) + ), +})) + +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('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..5cc786a2340 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts @@ -0,0 +1,286 @@ +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 { 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 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/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 30dbe6f925a..fdb6daeea6b 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -114,6 +114,93 @@ export const v2RollbackWorkflowContract = defineRouteContract({ }, }) +/** + * Structured execution error — mirrors `WorkflowExecutionErrorCode` in + * `@/executor/utils/errors` (duplicated literally: contracts are + * client-importable and must not pull executor modules). APPEND-ONLY: callers + * route on these instead of substring-matching messages. + */ +export const v2ExecutionErrorSchema = z.object({ + message: z.string(), + code: z.enum([ + 'TIMEOUT', + 'CANCELLED', + 'USAGE_LIMIT_EXCEEDED', + 'INVALID_INPUT', + 'BLOCK_EXECUTION_FAILED', + 'CHILD_WORKFLOW_FAILED', + 'OUTPUT_TOO_LARGE', + 'EXECUTION_FAILED', + ]), + /** Failing block, when attributable. Deliberately crosses the workspace boundary for shared/child workflows — the executionId + block context is the reproducible handle a caller hands the workflow provider. */ + blockId: z.string().optional(), + blockName: z.string().optional(), + blockType: z.string().optional(), +}) +export type V2ExecutionError = z.output + +/** + * Strict public execute body. Async is body-selected (`async: true`) — v2 has + * no `X-Execution-Mode`/`X-Stream-Response` headers. Internal caller facts + * (triggerType, draft state, deployment pinning) are NEVER wire fields; they + * are typed options on the execution service. + */ +export const v2ExecuteWorkflowBodySchema = z + .object({ + input: z.record(z.string(), z.unknown()).optional(), + async: z.boolean().optional().default(false), + stream: z.boolean().optional().default(false), + selectedOutputs: z.array(z.string().min(1)).max(100).optional(), + includeThinking: z.boolean().optional().default(false), + includeToolCalls: z.boolean().optional().default(false), + includeFileBase64: z.boolean().optional(), + /** Caps inline base64 file hydration; bounded (v1 leaves it unbounded). */ + base64MaxBytes: z + .number() + .int() + .positive() + .max(10 * 1024 * 1024) + .optional(), + }) + .strict() +export type V2ExecuteWorkflowBody = z.input + +/** + * The execution result resource. In-band run failures are `status: 'failed'` + * with a structured `error` — never an HTTP error: **an `executionId` means + * 200/202 + `data`; no `executionId` means the `v2Error` envelope.** The sync + * timeout is `status:'failed'` + `error.code:'TIMEOUT'` (v1 returned 408). + */ +export const v2ExecuteWorkflowDataSchema = z.object({ + executionId: z.string(), + workflowId: z.string(), + status: z.enum(['completed', 'failed', 'paused', 'cancelled']), + output: z.unknown(), + error: v2ExecutionErrorSchema.nullable(), + startedAt: z.string().optional(), + endedAt: z.string().optional(), + durationMs: z.number().optional(), +}) +export type V2ExecuteWorkflowData = z.output + +/** 202 receipt for `async: true` — poll `statusUrl` (the v2 executions resource). */ +export const v2ExecuteWorkflowQueuedSchema = z.object({ + executionId: z.string(), + statusUrl: z.string(), +}) +export type V2ExecuteWorkflowQueued = z.output + +export const v2ExecuteWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[id]/execute', + params: workflowIdParamsSchema, + body: v2ExecuteWorkflowBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ExecuteWorkflowDataSchema), + }, +}) + /** * Export/import reuse the v1 payload and body schemas verbatim — the portable * envelope must round-trip across both surfaces — with only the response diff --git a/apps/sim/lib/workflows/executor/execute-service.ts b/apps/sim/lib/workflows/executor/execute-service.ts index db45a761d32..d48d84ae722 100644 --- a/apps/sim/lib/workflows/executor/execute-service.ts +++ b/apps/sim/lib/workflows/executor/execute-service.ts @@ -1,10 +1,11 @@ import type { workflow as workflowTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' +import { generateId, isValidUuid } from '@sim/utils/id' import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { createTimeoutAbortController, getTimeoutErrorMessage } from '@/lib/core/execution-limits' +import { SSE_HEADERS } from '@/lib/core/utils/sse' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { validateCallChain } from '@/lib/execution/call-chain' import { processInputFileFields } from '@/lib/execution/files' @@ -15,6 +16,8 @@ import { LoggingSession } from '@/lib/logs/execution/logging-session' import { MAX_MCP_WORKFLOW_RESPONSE_BYTES } from '@/lib/mcp/constants' 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 { claimExecutionId, @@ -27,8 +30,14 @@ import { loadDeployedWorkflowState, loadWorkflowDeploymentVersionState, } from '@/lib/workflows/persistence/utils' +import { shouldEmitAgentStreamEvents } from '@/lib/workflows/streaming/agent-stream-protocol' +import { + agentStreamProtocolResponseHeaders, + createStreamingResponse, +} from '@/lib/workflows/streaming/streaming' import { workflowHasResponseBlock } from '@/lib/workflows/utils' import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay' +import { normalizeName } from '@/executor/constants' import { ExecutionSnapshot } from '@/executor/execution/snapshot' import type { ExecutionMetadata } from '@/executor/execution/types' import type { NormalizedBlockOutput } from '@/executor/types' @@ -81,6 +90,17 @@ export interface ExecuteWorkflowServiceParams { rateLimitCounter?: 'sync' | 'async' /** Outer request signal; aborting cancels the run (client disconnect). */ abortSignal?: AbortSignal + /** + * `sync` (default): run to completion and return the result resource. + * `async`: enqueue and return the queue receipt. + * `stream`: return an SSE Response (agent-stream protocol negotiated from + * `requestHeaders`). + */ + mode?: 'sync' | 'async' | 'stream' + /** Original request headers — stream-protocol negotiation only. */ + requestHeaders?: Headers + includeThinking?: boolean + includeToolCalls?: boolean } export interface ExecuteWorkflowServiceFailure { @@ -108,8 +128,23 @@ export interface ExecuteWorkflowServiceRun { durationMs?: number } +export interface ExecuteWorkflowServiceQueued { + ok: true + queued: true + executionId: string + jobId: string +} + +export interface ExecuteWorkflowServiceStream { + ok: true + stream: Response + executionId: string +} + export type ExecuteWorkflowServiceResult = | ExecuteWorkflowServiceRun + | ExecuteWorkflowServiceQueued + | ExecuteWorkflowServiceStream | { ok: false; failure: ExecuteWorkflowServiceFailure } function failure(f: ExecuteWorkflowServiceFailure): ExecuteWorkflowServiceResult { @@ -170,6 +205,10 @@ export async function executeWorkflowService( rejectLargeInlineOutput = false, rateLimitCounter = 'sync', abortSignal, + mode = 'sync', + requestHeaders, + includeThinking = false, + includeToolCalls = false, } = params let reqLogger = logger.withMetadata({ requestId, workflowId, userId }) @@ -276,8 +315,41 @@ export async function executeWorkflowService( } reqLogger = reqLogger.withMetadata({ workspaceId, userId: actorUserId }) + if (mode === 'async') { + const enqueue = await enqueueWorkflowExecution({ + requestId, + workflowId, + userId: actorUserId, + billingAttribution, + workspaceId, + input, + triggerType, + executionId, + callChain, + }) + executionIdClaimCommitted = enqueue.retainExecutionClaim + if (enqueue.outcome === 'rejected') { + return failure({ + kind: 'infra', + message: 'Failed to queue async execution', + statusCode: 500, + }) + } + if (enqueue.outcome === 'ambiguous') { + return failure({ + kind: 'infra', + message: 'Async execution queue acceptance could not be confirmed', + statusCode: 503, + code: 'ASYNC_ENQUEUE_AMBIGUOUS', + executionId, + }) + } + return { ok: true, queued: true, executionId, jobId: enqueue.jobId } + } + let processedInput = input let workflowVariables: Record = {} + let workflowBlocks: Record = {} try { const workflowData = deploymentVersionId ? await loadWorkflowDeploymentVersionState(workflowId, deploymentVersionId, workspaceId) @@ -289,6 +361,7 @@ export async function executeWorkflowService( } if (workflowData) { + workflowBlocks = workflowData.blocks workflowVariables = ('variables' in workflowData ? (workflowData.variables as Record | undefined) @@ -341,6 +414,88 @@ export async function executeWorkflowService( }) } + if (mode === 'stream') { + const resolvedSelectedOutputs = resolveOutputIds(selectedOutputs, workflowBlocks) + const streamWorkflow = { + id: workflow.id, + userId: actorUserId, + workspaceId, + isDeployed: workflow.isDeployed, + variables: workflowVariables, + } + const headers = requestHeaders ?? new Headers() + const agentEvents = shouldEmitAgentStreamEvents({ + includeThinking, + includeToolCalls, + requestHeaders: headers, + }) + + const stream = await createStreamingResponse({ + requestId, + streamConfig: { + selectedOutputs: resolvedSelectedOutputs, + isSecureMode: false, + workflowTriggerType: 'api', + includeFileBase64, + base64MaxBytes, + timeoutMs: preprocessResult.executionTimeout?.sync, + includeThinking, + includeToolCalls, + }, + executionId, + largeValueExecutionIds: [executionId], + largeValueKeys: [], + fileKeys: [], + workspaceId, + workflowId, + userId: actorUserId, + allowLargeValueWorkflowScope: false, + requestSignal: abortSignal, + requestHeaders: headers, + executeFn: async ({ onStream, onBlockComplete, abortSignal: streamAbortSignal }) => + executeWorkflow( + streamWorkflow, + requestId, + processedInput, + actorUserId, + { + enabled: true, + selectedOutputs: resolvedSelectedOutputs, + isSecureMode: false, + workflowTriggerType: 'api', + onStream, + onBlockComplete, + skipLoggingComplete: true, + includeFileBase64, + base64MaxBytes, + abortSignal: streamAbortSignal, + executionMode: 'stream', + billingAttribution, + largeValueKeys: [], + fileKeys: [], + includeThinking, + includeToolCalls, + agentEvents, + }, + executionId + ), + }) + + executionIdClaimCommitted = true + return { + ok: true, + executionId, + stream: new Response(stream, { + status: 200, + headers: { + ...SSE_HEADERS, + // Echo the negotiated stream protocol (same as the chat and v1 routes). + ...agentStreamProtocolResponseHeaders({ requestHeaders: headers }), + }, + }), + } + } + const metadata: ExecutionMetadata = { requestId, executionId, @@ -592,3 +747,61 @@ export async function executeWorkflowService( } } } + +/** + * Resolves caller-facing `selectedOutputs` refs (`BlockName.path` or + * `.path`) to internal `_` ids — same normalization the + * v1 streaming path applies. + */ +export function resolveOutputIds( + selectedOutputs: string[] | undefined, + blocks: Record +): string[] | undefined { + if (!selectedOutputs || selectedOutputs.length === 0) { + return selectedOutputs + } + + return selectedOutputs.map((outputId) => { + const underscoreIndex = outputId.indexOf('_') + const dotIndex = outputId.indexOf('.') + if (underscoreIndex > 0) { + const maybeUuid = outputId.substring(0, underscoreIndex) + if (isValidUuid(maybeUuid)) { + return outputId + } + } + + if (dotIndex > 0) { + const maybeUuid = outputId.substring(0, dotIndex) + if (isValidUuid(maybeUuid)) { + return `${outputId.substring(0, dotIndex)}_${outputId.substring(dotIndex + 1)}` + } + } + + if (isValidUuid(outputId)) { + return outputId + } + + if (dotIndex === -1) { + logger.warn(`Invalid output ID format (missing dot): ${outputId}`) + return outputId + } + + const blockName = outputId.substring(0, dotIndex) + const path = outputId.substring(dotIndex + 1) + + const normalizedBlockName = normalizeName(blockName) + const block = Object.values(blocks).find((candidate) => { + const record = candidate as { name?: string } + return normalizeName(record.name || '') === normalizedBlockName + }) + + if (!block) { + logger.warn(`Block not found for name: ${blockName} (from output ID: ${outputId})`) + return outputId + } + + const resolvedId = `${(block as { id: string }).id}_${path}` + return resolvedId + }) +} From 3387ee7d9f837c07e2c292ceb5d5370ba47708d2 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 10:58:04 -0700 Subject: [PATCH 10/24] feat(api): v2 executions status + cancel with queued backfill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/v2/workflows/[id]/executions/[executionId] is the single status URL for sync and async runs: before the async worker writes the durable log row, status is backfilled from the job queue (deterministic job id) as 'queued'/'running' — closing v1's 202-to-pickup 404 window — and failed runs carry the structured error object. POST .../cancel renders the shared cancellation lib in the v2 envelope with the tightened 5-value reason enum. Both authenticate via the shared resolveV2WorkflowAccess (X-API-Key, authz masked as 404, allowPersonalApiKeys honored). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- .../executions/[executionId]/cancel/route.ts | 48 +++++ .../executions/[executionId]/route.test.ts | 171 ++++++++++++++++++ .../[id]/executions/[executionId]/route.ts | 123 +++++++++++++ apps/sim/app/api/v2/workflows/lib/access.ts | 59 ++++++ apps/sim/lib/api/contracts/v2/workflows.ts | 63 ++++++- apps/sim/lib/api/contracts/workflows.ts | 4 +- 6 files changed, 465 insertions(+), 3 deletions(-) create mode 100644 apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/cancel/route.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.ts create mode 100644 apps/sim/app/api/v2/workflows/lib/access.ts 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..6f7bc473a22 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts @@ -0,0 +1,171 @@ +/** + * @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:', +})) + +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..69b52690186 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.ts @@ -0,0 +1,123 @@ +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 { 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) { + 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/lib/access.ts b/apps/sim/app/api/v2/workflows/lib/access.ts new file mode 100644 index 00000000000..765933793ee --- /dev/null +++ b/apps/sim/app/api/v2/workflows/lib/access.ts @@ -0,0 +1,59 @@ +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 { 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 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/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index fdb6daeea6b..b721f347b7c 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -9,7 +9,13 @@ import { v1WorkflowExportPayloadSchema, } from '@/lib/api/contracts/v1/workflows' import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' -import { workflowIdParamsSchema } from '@/lib/api/contracts/workflows' +import { + cancelWorkflowExecutionReasonSchema, + workflowExecutionParamsSchema, + workflowExecutionPausedDetailSchema, + workflowExecutionStatusQuerySchema, + workflowIdParamsSchema, +} from '@/lib/api/contracts/workflows' /** * v2 workflows contracts. Request shapes are reused verbatim from v1 (the list @@ -201,6 +207,61 @@ export const v2ExecuteWorkflowContract = defineRouteContract({ }, }) +/** + * The polled execution resource. `queued` is backfilled from the async job + * queue before the worker writes the durable log row — v1's jobs endpoint 404 + * window doesn't exist here. `error` is the same structured object the execute + * response carries. + */ +export const v2WorkflowExecutionStatusSchema = z.object({ + executionId: z.string(), + workflowId: z.string(), + status: z.enum(['queued', 'pending', 'running', 'completed', 'failed', 'cancelled', 'paused']), + trigger: z.string().nullable(), + startedAt: z.string().nullable(), + endedAt: z.string().nullable(), + durationMs: z.number().nullable(), + paused: workflowExecutionPausedDetailSchema.nullable(), + cost: z.object({ total: z.number() }).nullable(), + error: v2ExecutionErrorSchema.nullable(), + /** Populated only with `includeOutput=true` on completed runs. */ + output: z.unknown().nullable(), + blockOutputs: z.record(z.string(), z.unknown()).nullable(), +}) +export type V2WorkflowExecutionStatus = z.output + +export const v2GetWorkflowExecutionContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[id]/executions/[executionId]', + params: workflowExecutionParamsSchema, + query: workflowExecutionStatusQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowExecutionStatusSchema), + }, +}) + +export const v2CancelWorkflowExecutionDataSchema = z.object({ + success: z.boolean(), + executionId: z.string(), + redisAvailable: z.boolean(), + durablyRecorded: z.boolean(), + locallyAborted: z.boolean(), + pausedCancelled: z.boolean(), + reason: cancelWorkflowExecutionReasonSchema.optional(), +}) +export type V2CancelWorkflowExecutionData = z.output + +export const v2CancelWorkflowExecutionContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[id]/executions/[executionId]/cancel', + params: workflowExecutionParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CancelWorkflowExecutionDataSchema), + }, +}) + /** * Export/import reuse the v1 payload and body schemas verbatim — the portable * envelope must round-trip across both surfaces — with only the response diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index b4228468fc7..d429917fe4b 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -551,7 +551,7 @@ const workflowExecutionStatusEnum = z.enum([ 'cancelled', ]) -const workflowExecutionPausedDetailSchema = z.object({ +export const workflowExecutionPausedDetailSchema = z.object({ pausedAt: z.string(), resumeAt: z.string().nullable(), pauseKind: z.enum(['time', 'human']).nullable(), @@ -580,7 +580,7 @@ const workflowExecutionStatusResponseSchema = z.object({ export type WorkflowExecutionStatusResponse = z.output -const workflowExecutionStatusQuerySchema = z.object({ +export const workflowExecutionStatusQuerySchema = z.object({ includeOutput: z .enum(['true', 'false']) .optional() From c411f6e10bcde756d4ae0c4dbf14ba8a79ea2349 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 11:05:30 -0700 Subject: [PATCH 11/24] feat(execution): workflow tool + MCP bridge run in-process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workflow_executor (workflow-as-agent-tool) short-circuits in executeTool through WorkflowBlockHandler — the same invocation boundary canvas child workflows use — mirroring the deployed_block_executor precedent. The MCP serve bridge calls executeWorkflowService directly instead of fetching its own execute endpoint; deployment-version pinning, MCP response-size rejection, and the actor override become typed options instead of header sniffing. Both callers drop the double admission slot and duplicate top-level log row the HTTP hop cost, and failed child runs now surface the structured error + child executionId so parents and MCP clients can route on error class and hand providers a reproducible handle. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- .../api/mcp/serve/[serverId]/route.test.ts | 384 ++++++++++-------- .../sim/app/api/mcp/serve/[serverId]/route.ts | 164 ++++---- .../workflow/custom-block-tool-runner.ts | 2 +- .../handlers/workflow/workflow-tool-runner.ts | 93 +++++ apps/sim/tools/index.ts | 21 + 5 files changed, 411 insertions(+), 253 deletions(-) create mode 100644 apps/sim/executor/handlers/workflow/workflow-tool-runner.ts 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/executor/handlers/workflow/custom-block-tool-runner.ts b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts index bd9db63949c..5f615bf975b 100644 --- a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts +++ b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts @@ -14,7 +14,7 @@ import type { ToolResponse } from '@/tools/types' const logger = createLogger('CustomBlockToolRunner') /** Server-set execution context propagated to every agent tool call. */ -interface CustomBlockExecutorContext { +export interface CustomBlockExecutorContext { workspaceId?: string userId?: string workflowId?: string diff --git a/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts b/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts new file mode 100644 index 00000000000..e4905adacb1 --- /dev/null +++ b/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts @@ -0,0 +1,93 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { ChildWorkflowError } from '@/executor/errors/child-workflow-error' +import { + buildCustomBlockExecutionContext, + type CustomBlockExecutorContext, +} from '@/executor/handlers/workflow/custom-block-tool-runner' +import { + aggregateChildCost, + WorkflowBlockHandler, +} from '@/executor/handlers/workflow/workflow-handler' +import { classifyExecutionError } from '@/executor/utils/errors' +import { parseJSON } from '@/executor/utils/json' +import type { SerializedBlock } from '@/serializer/types' +import type { ToolResponse } from '@/tools/types' + +const logger = createLogger('WorkflowToolRunner') + +interface WorkflowToolParams { + workflowId?: string + inputMapping?: Record | string + _context?: CustomBlockExecutorContext +} + +/** + * Runs a workflow selected as an Agent tool (`workflow_executor`) in-process + * via `WorkflowBlockHandler` — the same invocation boundary canvas child + * workflows use — replacing the historical HTTP hop to the execute endpoint. + * One admission slot, one top-level log row, cost rolled into the parent + * trace, and the workspace assert / call-chain depth / deployment checks the + * handler already enforces. + * + * On failure the result carries the structured error + the child executionId + * in `output` so parent workflows can route on `error.code` and report a + * reproducible handle to the workflow's provider. + */ +export async function runWorkflowTool(params: WorkflowToolParams): Promise { + if (!params.workflowId) { + return { success: false, output: {}, error: 'Missing workflowId' } + } + + const ctx = buildCustomBlockExecutionContext(params._context ?? {}) + const block: SerializedBlock = { + id: generateId(), + position: { x: 0, y: 0 }, + config: { tool: 'workflow_executor', params: {} }, + inputs: {}, + outputs: {}, + metadata: { id: 'workflow_input' }, + enabled: true, + } + + let inputMapping = params.inputMapping ?? {} + if (typeof inputMapping === 'string') { + inputMapping = parseJSON(inputMapping, {}) as Record + } + + try { + const output = await new WorkflowBlockHandler().execute(ctx, block, { + workflowId: params.workflowId, + inputMapping, + }) + const normalized: Record = + output && typeof output === 'object' && !Array.isArray(output) + ? (output as Record) + : { result: output } + return { success: true, output: normalized } + } catch (error) { + const message = getErrorMessage(error, 'Workflow execution failed') + const isChildError = ChildWorkflowError.isChildWorkflowError(error) + const failedChildSpans = isChildError ? error.childTraceSpans : [] + const childCost = aggregateChildCost(failedChildSpans) + const executionResult = isChildError ? error.executionResult : undefined + const structured = classifyExecutionError(error, executionResult) + const childExecutionId = executionResult?.metadata?.executionId + + logger.info('Workflow tool execution failed', { + workflowId: params.workflowId, + message, + code: structured.code, + }) + return { + success: false, + output: { + ...(childCost > 0 ? { cost: { total: childCost } } : {}), + ...(childExecutionId ? { executionId: childExecutionId } : {}), + error: structured, + }, + error: message, + } + } +} diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index f19c4537d88..9e6fd36b6a5 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -1271,6 +1271,27 @@ export async function executeTool( // tool registry never pulls in the executor/db dependency graph (a static or // dynamic executor import in the tool descriptor itself would break the client // build — and with it `getTool('workflow_executor')`). + // Workflow-as-agent-tool runs in-process through WorkflowBlockHandler — + // the same invocation boundary canvas child workflows use. Replaces the + // historical HTTP hop to /api/workflows/{id}/execute (double admission + // slot + duplicate top-level log row); billing/observability now match the + // canvas workflow block. + if (normalizedToolId === 'workflow_executor') { + logger.info(`[${requestId}] Running workflow tool ${toolId} in-process`) + const { runWorkflowTool } = await import('@/executor/handlers/workflow/workflow-tool-runner') + const result = await runWorkflowTool(contextParams) + const endTime = new Date() + return { + ...result, + output: postProcessToolOutput(normalizedToolId, result.output ?? {}), + timing: { + startTime: startTimeISO, + endTime: endTime.toISOString(), + duration: endTime.getTime() - startTime.getTime(), + }, + } + } + if (normalizedToolId === 'deployed_block_executor') { logger.info(`[${requestId}] Running custom block tool ${toolId}`) const { runCustomBlockTool } = await import( From 63fcfe29186ab5bd023b9c53ad0d54acd321a3f4 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 11:06:20 -0700 Subject: [PATCH 12/24] feat(infra): CORS + CSP coverage for the v2 execute path /api/v2/workflows/:id/execute gets the same wildcard-origin, credential-free CORS policy as v1 (the default credentialed policy would block browser API-key calls and open a cookie CSRF surface) with X-Sim-Stream-Protocol allowed and no X-Execution-Mode (async is body-selected on v2), plus the COEP/COOP/CSP header block. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- apps/sim/next.config.ts | 11 +++++++++++ apps/sim/proxy.test.ts | 24 ++++++++++++++++++++++++ apps/sim/proxy.ts | 15 +++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 463c5683ce9..b72bec6f1ec 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -254,6 +254,17 @@ const nextConfig: NextConfig = { }, ], }, + { + source: '/api/v2/workflows/:id/execute', + headers: [ + { key: 'Cross-Origin-Embedder-Policy', value: 'unsafe-none' }, + { key: 'Cross-Origin-Opener-Policy', value: 'unsafe-none' }, + { + key: 'Content-Security-Policy', + value: getWorkflowExecutionCSPPolicy(), + }, + ], + }, { // Exclude Vercel internal resources and static assets from strict COEP, Google Drive Picker // and the /demo Cal.com booking embed to prevent 'refused to connect' / slow-load issues diff --git a/apps/sim/proxy.test.ts b/apps/sim/proxy.test.ts index 3dc7da2be17..2ee2476e2c4 100644 --- a/apps/sim/proxy.test.ts +++ b/apps/sim/proxy.test.ts @@ -90,6 +90,29 @@ describe('resolveApiCorsPolicy', () => { expect(policy.headers).toContain('X-Execution-Id') }) + it('serves v2 workflow execute with wildcard origin and the stream-protocol header', () => { + const policy = resolveApiCorsPolicy( + makeRequest('/api/v2/workflows/workflow-123/execute', 'https://other.example') + ) + expect(policy.origin).toBe('*') + expect(policy.credentials).toBe(false) + expect(policy.headers).toContain('X-Execution-Id') + expect(policy.headers).toContain('X-Sim-Stream-Protocol') + // Async is body-selected on v2 — the mode header is deliberately absent. + expect(policy.headers).not.toContain('X-Execution-Mode') + }) + + it('does not match the v2 execute rule for nested or executions paths', () => { + const nested = resolveApiCorsPolicy( + makeRequest('/api/v2/workflows/workflow-123/execute/extra', 'https://other.example') + ) + expect(nested.origin).toBe('https://app.sim.test') + const executions = resolveApiCorsPolicy( + makeRequest('/api/v2/workflows/workflow-123/executions/e-1', 'https://other.example') + ) + expect(executions.origin).toBe('https://app.sim.test') + }) + it('does not match the workflow execute rule for nested paths', () => { const policy = resolveApiCorsPolicy( makeRequest('/api/workflows/workflow-123/execute/extra', 'https://other.example') @@ -113,6 +136,7 @@ describe('resolveApiCorsPolicy', () => { '/api/mcp/copilot', '/api/chat/abc', '/api/workflows/wf/execute', + '/api/v2/workflows/wf/execute', '/api/files/upload', ] for (const path of paths) { diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index 73d03e9b797..394151d3a51 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -23,6 +23,9 @@ const DEFAULT_API_ALLOWED_HEADERS = const WORKFLOW_EXECUTE_HEADERS = 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, X-Execution-Id' +/** v2 execute: async is body-selected (no X-Execution-Mode) and streaming negotiates X-Sim-Stream-Protocol. */ +const WORKFLOW_EXECUTE_V2_HEADERS = `${WORKFLOW_EXECUTE_HEADERS}, X-Sim-Stream-Protocol` + /** Subpaths under /api/chat/* that serve the workspace UI, not embeds. */ const EMBED_RESERVED_SEGMENTS = new Set(['manage', 'validate']) @@ -82,6 +85,18 @@ const CORS_RULES: readonly CorsRule[] = [ headers: WORKFLOW_EXECUTE_HEADERS, }), }, + { + // Mirrors the v1 rule: public execute endpoints are wildcard-origin and + // credential-free — the default credentialed policy would both block + // browser API-key calls and open a cookie-bearing CSRF surface. + match: (p) => /^\/api\/v2\/workflows\/[^/]+\/execute$/.test(p), + policy: () => ({ + origin: '*', + credentials: false, + methods: 'POST,OPTIONS', + headers: WORKFLOW_EXECUTE_V2_HEADERS, + }), + }, ] /** Single source of truth for /api/* CORS — resolved at request time, not baked at build. */ From 272de325b98d2130b3f21c5ea99a53755fb09e01 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 11:09:40 -0700 Subject: [PATCH 13/24] feat(ui): deploy modal + copilot advertise the v2 execute surface All 20 API-tab snippets move to POST /api/v2/workflows/{id}/execute with the nested {"input": ...} body, async as the "async": true body flag (X-Execution-Mode gone), status polling against the v2 executions resource, the third tab renamed Usage and pointed at /api/v2/billing/usage, and {data} envelope unwraps in the printed responses. Fixes the latent baseUrl derivation (endpoint.split('/api/workflows/')) that would have silently built garbage URLs under a v2 endpoint, and deletes dead code (exampleCommand across 3 sites, getAsyncExampleTitle). Copilot deploy/manage/serializer endpoint builders and the api_trigger bestPractices example follow (the latter also drops its hardcoded staging host). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- .../deploy-modal/components/api/api.tsx | 96 ++++++++----------- .../components/deploy-modal/deploy-modal.tsx | 4 +- apps/sim/blocks/blocks/api_trigger.ts | 2 +- .../tools/handlers/deployment/deploy.ts | 12 +-- .../tools/handlers/deployment/manage.ts | 2 +- apps/sim/lib/copilot/vfs/serializers.ts | 2 +- 6 files changed, 51 insertions(+), 67 deletions(-) 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 a5950e82f60..9dff329a647 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 @@ -12,6 +12,7 @@ import { Tooltip, } from '@sim/emcn' import { Check, Clipboard } from 'lucide-react' +import { getBaseUrl } from '@/lib/core/utils/urls' import { AGENT_STREAM_PROTOCOL_HEADER_LABEL, AGENT_STREAM_PROTOCOL_V1, @@ -24,7 +25,6 @@ interface WorkflowDeploymentInfo { deployedAt?: string apiKey: string endpoint: string - exampleCommand: string needsRedeployment: boolean isPublicApi?: boolean } @@ -39,7 +39,7 @@ interface ApiDeployProps { onSelectedStreamingOutputsChange: (outputs: string[]) => void } -type AsyncExampleType = 'execute' | 'status' | 'rate-limits' +type AsyncExampleType = 'execute' | 'status' | 'usage' type CodeLanguage = 'curl' | 'python' | 'javascript' | 'typescript' type CopiedState = { @@ -97,19 +97,23 @@ export function ApiDeploy({ return info.endpoint.replace(info.apiKey, '$SIM_API_KEY') } - const getPayloadObject = (): Record => { + /** The workflow's example input fields, parsed from the shared example command. */ + const getInputObject = (): Record => { const inputExample = getInputFormatExample ? getInputFormatExample(false) : '' const match = inputExample.match(/-d\s*'([\s\S]*)'/) if (match) { try { return JSON.parse(match[1]) as Record } catch { - return { input: 'your data here' } + return { key: 'value' } } } - return { input: 'your data here' } + return { key: 'value' } } + /** v2 body: the input nests under `input`; control fields are siblings. */ + const getPayloadObject = (): Record => ({ input: getInputObject() }) + const getStreamPayloadObject = (): Record => { const payload: Record = { ...getPayloadObject(), stream: true } if (selectedStreamingOutputs && selectedStreamingOutputs.length > 0) { @@ -148,7 +152,7 @@ ${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')} ) -print(response.json())` +print(response.json()["data"])` case 'javascript': return `const response = await fetch("${endpoint}", { @@ -159,7 +163,7 @@ ${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Typ body: JSON.stringify(${JSON.stringify(payload)}) }); -const data = await response.json(); +const { data } = await response.json(); console.log(data);` case 'typescript': @@ -171,7 +175,7 @@ ${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Typ body: JSON.stringify(${JSON.stringify(payload)}) }); -const data: Record = await response.json(); +const { data }: { data: Record } = await response.json(); console.log(data);` default: @@ -261,8 +265,8 @@ while (true) { const getAsyncCommand = (): string => { if (!info) return '' const endpoint = getBaseEndpoint() - const baseUrl = endpoint.split('/api/workflows/')[0] - const payload = getPayloadObject() + const baseUrl = getBaseUrl() + const payload = { ...getPayloadObject(), async: true } const isPublic = info.isPublicApi switch (asyncExampleType) { @@ -271,7 +275,6 @@ while (true) { case 'curl': return `curl -X POST \\ ${isPublic ? '' : ' -H "X-API-Key: $SIM_API_KEY" \\\n'} -H "Content-Type: application/json" \\ - -H "X-Execution-Mode: async" \\ -d '${JSON.stringify(payload)}' \\ ${endpoint}` @@ -282,40 +285,38 @@ import requests response = requests.post( "${endpoint}", headers={ -${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} "Content-Type": "application/json", - "X-Execution-Mode": "async" +${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} "Content-Type": "application/json" }, json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')} ) -job = response.json() -print(job) # Contains jobId and executionId` +job = response.json()["data"] +print(job) # Contains executionId and statusUrl` case 'javascript': return `const response = await fetch("${endpoint}", { method: "POST", headers: { -${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json", - "X-Execution-Mode": "async" +${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json" }, body: JSON.stringify(${JSON.stringify(payload)}) }); -const job = await response.json(); -console.log(job); // Contains jobId and executionId` +const { data: job } = await response.json(); +console.log(job); // Contains executionId and statusUrl` case 'typescript': return `const response = await fetch("${endpoint}", { method: "POST", headers: { -${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json", - "X-Execution-Mode": "async" +${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json" }, body: JSON.stringify(${JSON.stringify(payload)}) }); -const job: { jobId: string; executionId: string } = await response.json(); -console.log(job); // Contains jobId and executionId` +const { data: job }: { data: { executionId: string; statusUrl: string } } = + await response.json(); +console.log(job); // Poll statusUrl until status is terminal` default: return '' @@ -325,84 +326,84 @@ console.log(job); // Contains jobId and executionId` switch (language) { case 'curl': return `curl -H "X-API-Key: $SIM_API_KEY" \\ - ${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION` + ${baseUrl}/api/v2/workflows/${workflowId}/executions/EXECUTION_ID` case 'python': return `import os import requests response = requests.get( - "${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION", + "${baseUrl}/api/v2/workflows/${workflowId}/executions/EXECUTION_ID", headers={"X-API-Key": os.environ.get("SIM_API_KEY")} ) -status = response.json() -print(status)` +status = response.json()["data"] +print(status) # status: queued | running | completed | failed | cancelled | paused` case 'javascript': return `const response = await fetch( - "${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION", + "${baseUrl}/api/v2/workflows/${workflowId}/executions/EXECUTION_ID", { headers: { "X-API-Key": process.env.SIM_API_KEY } } ); -const status = await response.json(); +const { data: status } = await response.json(); console.log(status);` case 'typescript': return `const response = await fetch( - "${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION", + "${baseUrl}/api/v2/workflows/${workflowId}/executions/EXECUTION_ID", { headers: { "X-API-Key": process.env.SIM_API_KEY } } ); -const status: Record = await response.json(); +const { data: status }: { data: Record } = await response.json(); console.log(status);` default: return '' } - case 'rate-limits': + case 'usage': switch (language) { case 'curl': return `curl -H "X-API-Key: $SIM_API_KEY" \\ - ${baseUrl}/api/users/me/usage-limits` + ${baseUrl}/api/v2/billing/usage` case 'python': return `import os import requests response = requests.get( - "${baseUrl}/api/users/me/usage-limits", + "${baseUrl}/api/v2/billing/usage", headers={"X-API-Key": os.environ.get("SIM_API_KEY")} ) -limits = response.json() -print(limits)` +limits = response.json()["data"] +print(limits) # totalCredits + bySourceCredits breakdown` case 'javascript': return `const response = await fetch( - "${baseUrl}/api/users/me/usage-limits", + "${baseUrl}/api/v2/billing/usage", { headers: { "X-API-Key": process.env.SIM_API_KEY } } ); -const limits = await response.json(); +const { data: limits } = await response.json(); console.log(limits);` case 'typescript': return `const response = await fetch( - "${baseUrl}/api/users/me/usage-limits", + "${baseUrl}/api/v2/billing/usage", { headers: { "X-API-Key": process.env.SIM_API_KEY } } ); -const limits: Record = await response.json(); +const { data: limits }: { data: Record } = await response.json(); console.log(limits);` default: @@ -414,19 +415,6 @@ console.log(limits);` } } - const getAsyncExampleTitle = () => { - switch (asyncExampleType) { - case 'execute': - return 'Execute Job' - case 'status': - return 'Check Status' - case 'rate-limits': - return 'Usage Limits' - default: - return 'Execute Job' - } - } - const handleCopy = (key: keyof CopiedState, value: string) => { navigator.clipboard.writeText(value) setCopied((prev) => ({ ...prev, [key]: true })) @@ -564,7 +552,7 @@ console.log(limits);` options={[ { label: 'Execute Job', value: 'execute' }, { label: 'Check Status', value: 'status' }, - { label: 'Usage Limits', value: 'rate-limits' }, + { label: 'Usage', value: 'usage' }, ]} value={asyncExampleType} onChange={(value) => setAsyncExampleType(value as AsyncExampleType)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index aa33d96cc92..91bb280288e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -77,7 +77,6 @@ interface WorkflowDeploymentInfoUI { deployedAt?: string apiKey: string endpoint: string - exampleCommand: string needsRedeployment: boolean isPublicApi: boolean } @@ -234,7 +233,7 @@ export function DeployModal({ return null } - const endpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute` + const endpoint = `${getBaseUrl()}/api/v2/workflows/${workflowId}/execute` const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) const placeholderKey = getApiHeaderPlaceholder() @@ -243,7 +242,6 @@ export function DeployModal({ deployedAt: deploymentInfoData.deployedAt ?? undefined, apiKey: getApiKeyLabel(deploymentInfoData.apiKey), endpoint, - exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`, needsRedeployment: deploymentInfoData.needsRedeployment, isPublicApi: isPublicApiDisabled ? false : (deploymentInfoData.isPublicApi ?? false), } diff --git a/apps/sim/blocks/blocks/api_trigger.ts b/apps/sim/blocks/blocks/api_trigger.ts index 27ad2beef33..9c264b78fd9 100644 --- a/apps/sim/blocks/blocks/api_trigger.ts +++ b/apps/sim/blocks/blocks/api_trigger.ts @@ -11,7 +11,7 @@ export const ApiTriggerBlock: BlockConfig = { bestPractices: ` - Can run the workflow manually to test implementation when this is the trigger point. - The input format determines variables accesssible in the following blocks. E.g. . You can set the value in the input format to test the workflow manually. - - In production, the curl would come in as e.g. curl -X POST -H "X-API-Key: $SIM_API_KEY" -H "Content-Type: application/json" -d '{"paramName":"example"}' https://www.staging.sim.ai/api/workflows/9e7e4f26-fc5e-4659-b270-7ea474b14f4a/execute -- If user asks to test via API, you might need to clarify the API key. + - In production, the curl would come in as e.g. curl -X POST -H "X-API-Key: $SIM_API_KEY" -H "Content-Type: application/json" -d '{"input":{"paramName":"example"}}' https://www.sim.ai/api/v2/workflows/9e7e4f26-fc5e-4659-b270-7ea474b14f4a/execute -- If user asks to test via API, you might need to clarify the API key. `, category: 'triggers', hideFromToolbar: true, diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index f0d14b9ba29..3ebc74b6fee 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -30,7 +30,7 @@ import { ensureWorkflowAccess } from '../access' import type { DeployApiParams, DeployChatParams, DeployMcpParams } from '../param-types' function buildWorkflowApiEndpoint(baseUrl: string, workflowId: string): string { - return `${baseUrl}/api/workflows/${workflowId}/execute` + return `${baseUrl}/api/v2/workflows/${workflowId}/execute` } function buildWorkflowApiConfig(baseUrl: string, apiEndpoint: string) { @@ -57,9 +57,8 @@ function buildWorkflowApiConfig(baseUrl: string, apiEndpoint: string) { method: 'POST', transport: 'json', stream: false, - headers: { 'X-Execution-Mode': 'async' }, - body: { input: { key: 'value' } }, - jobStatusEndpointTemplate: `${baseUrl}/api/jobs/{jobId}`, + body: { async: true, input: { key: 'value' } }, + jobStatusEndpointTemplate: `${baseUrl}/api/v2/workflows/{workflowId}/executions/{executionId}`, }, }, } @@ -78,9 +77,8 @@ function buildWorkflowApiExamples(baseUrl: string, apiEndpoint: string) { async: `curl -X POST "${apiEndpoint}" \\ -H "Content-Type: application/json" \\ -H "X-API-Key: YOUR_API_KEY" \\ - -H "X-Execution-Mode: async" \\ - -d '{"input":{"key":"value"}}'`, - poll: `curl "${baseUrl}/api/jobs/JOB_ID" \\ + -d '{"async":true,"input":{"key":"value"}}'`, + poll: `curl "${baseUrl}/api/v2/workflows/WORKFLOW_ID/executions/EXECUTION_ID" \\ -H "X-API-Key: YOUR_API_KEY"`, } } diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index ff61d1762d1..a649f6c7112 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -82,7 +82,7 @@ export async function executeCheckDeploymentStatus( const apiDetails = { isDeployed: isApiDeployed, deployedAt: apiDeploy[0]?.deployedAt || null, - endpoint: isApiDeployed ? `/api/workflows/${workflowId}/execute` : null, + endpoint: isApiDeployed ? `/api/v2/workflows/${workflowId}/execute` : null, apiKey: workflowRecord.workspaceId ? 'Workspace API keys' : 'Personal API keys', needsRedeployment, activeDeployment: deploymentSummary.activeDeployment, diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index d394ed07347..789c1044eb0 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -808,7 +808,7 @@ export function serializeDeployments(data: DeploymentData): string { result.api = { isDeployed: true, deployedAt: data.deployedAt?.toISOString(), - apiEndpoint: `/api/workflows/${data.workflowId}/execute`, + apiEndpoint: `/api/v2/workflows/${data.workflowId}/execute`, ...(data.api ? { version: data.api.version } : {}), } } From 35d306de755353bdeaba58c3b99fc2db7c03154a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 11:13:09 -0700 Subject: [PATCH 14/24] docs(api): document the v2 execution surface Adds execute, execution status, and cancel to openapi-v2-workflows.json with the structured ExecutionError schema (append-only code enum + block attribution) and the ExecutionResource contract, documenting the rules that differ from v1: modes are body-selected, a failed run is HTTP 200 with status 'failed', an executionId always means data (never the error envelope), queued status is visible immediately, and Response-block payloads stay inside output. Registers the three pages in the generated workflows meta.json and bumps the route-count baseline. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- .../(generated)/workflows/meta.json | 5 +- apps/docs/openapi-v2-workflows.json | 623 ++++++++++++++++++ scripts/check-api-validation-contracts.ts | 4 +- 3 files changed, 629 insertions(+), 3 deletions(-) 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 ca2603a1d54..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 @@ -6,6 +6,9 @@ "importWorkflow", "deployWorkflow", "undeployWorkflow", - "rollbackWorkflow" + "rollbackWorkflow", + "executeWorkflowV2", + "getWorkflowExecutionV2", + "cancelExecutionV2" ] } diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 088c75fe278..95b85a369f2 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -684,6 +684,558 @@ } } } + }, + "/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" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "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." + }, + "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" + } + } + } } }, "components": { @@ -1140,6 +1692,77 @@ "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" + } + } } }, "responses": { diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 8853257bcc2..ccdeb5e3d4c 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1022, - zodRoutes: 1022, + totalRoutes: 1025, + zodRoutes: 1025, nonZodRoutes: 0, } as const From 53ea6a805001f513a2bfb12436f7ad6fc8bc07e7 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 12:26:02 -0700 Subject: [PATCH 15/24] feat(api): gate the whole /api/v2 surface behind one flag; UI stays on v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every v2 route now runs exactly one check immediately after auth — v2ApiGateError — and answers 404 when the `v2-api` flag is off, so the surface is invisible until it is deliberately rolled out. The gate is keyed on userId only: a workspace/org-keyed check would have to read membership for a caller-supplied id before authorization runs, and its 404-vs-403 split would leak cohort membership (the trap the per-domain table gate worked around by running late). The two executions routes inherit it from the shared access resolver; the tables-specific gate is removed so no route checks twice. `tables-v2-api` stays, now gating only the internal predicate-grammar route /api/table/[tableId]/query — note v2 tables routes move to the unified flag, so enabling them is a `v2-api` decision now. Reverts the deploy modal, copilot handlers, and api_trigger example to the v1 execute endpoint: v1 works unchanged, and the UI must not advertise a surface most users would get a 404 from. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- apps/sim/app/api/v2/audit-logs/[id]/route.ts | 4 + apps/sim/app/api/v2/audit-logs/route.ts | 4 + .../api/v2/billing/usage/logs/route.test.ts | 4 + .../app/api/v2/billing/usage/logs/route.ts | 5 + .../app/api/v2/billing/usage/route.test.ts | 4 + apps/sim/app/api/v2/billing/usage/route.ts | 5 + apps/sim/app/api/v2/files/[fileId]/route.ts | 9 ++ apps/sim/app/api/v2/files/route.ts | 9 ++ .../[id]/documents/[documentId]/route.ts | 9 ++ .../api/v2/knowledge/[id]/documents/route.ts | 9 ++ apps/sim/app/api/v2/knowledge/[id]/route.ts | 13 +++ apps/sim/app/api/v2/knowledge/route.ts | 9 ++ apps/sim/app/api/v2/knowledge/search/route.ts | 5 + apps/sim/app/api/v2/lib/gate.ts | 23 +++++ apps/sim/app/api/v2/logs/[id]/route.ts | 5 + .../v2/logs/executions/[executionId]/route.ts | 5 + apps/sim/app/api/v2/logs/route.ts | 5 + .../api/v2/tables/[tableId]/columns/route.ts | 24 +++-- .../v2/tables/[tableId]/query/route.test.ts | 40 +++----- .../api/v2/tables/[tableId]/query/route.ts | 10 +- apps/sim/app/api/v2/tables/[tableId]/route.ts | 17 ++-- .../v2/tables/[tableId]/rows/[rowId]/route.ts | 24 +++-- .../app/api/v2/tables/[tableId]/rows/route.ts | 33 +++---- .../v2/tables/[tableId]/rows/upsert/route.ts | 10 +- apps/sim/app/api/v2/tables/route.test.ts | 41 ++------ apps/sim/app/api/v2/tables/route.ts | 17 ++-- apps/sim/app/api/v2/tables/utils.ts | 19 ---- .../app/api/v2/workflows/[id]/deploy/route.ts | 9 ++ .../v2/workflows/[id]/execute/route.test.ts | 16 ++++ .../api/v2/workflows/[id]/execute/route.ts | 4 + .../executions/[executionId]/route.test.ts | 4 + .../app/api/v2/workflows/[id]/export/route.ts | 5 + .../api/v2/workflows/[id]/rollback/route.ts | 5 + apps/sim/app/api/v2/workflows/[id]/route.ts | 5 + apps/sim/app/api/v2/workflows/import/route.ts | 5 + apps/sim/app/api/v2/workflows/lib/access.ts | 4 + apps/sim/app/api/v2/workflows/route.ts | 5 + .../deploy-modal/components/api/api.tsx | 96 +++++++++++-------- .../components/deploy-modal/deploy-modal.tsx | 4 +- apps/sim/blocks/blocks/api_trigger.ts | 2 +- .../tools/handlers/deployment/deploy.ts | 12 ++- .../tools/handlers/deployment/manage.ts | 2 +- apps/sim/lib/copilot/vfs/serializers.ts | 2 +- apps/sim/lib/core/config/env.ts | 1 + apps/sim/lib/core/config/feature-flags.ts | 9 ++ 45 files changed, 364 insertions(+), 188 deletions(-) create mode 100644 apps/sim/app/api/v2/lib/gate.ts diff --git a/apps/sim/app/api/v2/audit-logs/[id]/route.ts b/apps/sim/app/api/v2/audit-logs/[id]/route.ts index d1fca3d0aa0..bef7e2d43ce 100644 --- a/apps/sim/app/api/v2/audit-logs/[id]/route.ts +++ b/apps/sim/app/api/v2/audit-logs/[id]/route.ts @@ -12,6 +12,7 @@ import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query' import { checkRateLimit } 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('V2AuditLogDetailAPI') @@ -38,6 +39,9 @@ export const GET = withRouteHandler( 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) diff --git a/apps/sim/app/api/v2/audit-logs/route.ts b/apps/sim/app/api/v2/audit-logs/route.ts index c785ccaaede..a264e5f47a4 100644 --- a/apps/sim/app/api/v2/audit-logs/route.ts +++ b/apps/sim/app/api/v2/audit-logs/route.ts @@ -14,6 +14,7 @@ import { queryAuditLogs, } from '@/app/api/v1/audit-logs/query' import { checkRateLimit } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CursorList, v2Error, @@ -44,6 +45,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { 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) 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 index 88cde13aa69..ed7d0390381 100644 --- a/apps/sim/app/api/v2/billing/usage/logs/route.test.ts +++ b/apps/sim/app/api/v2/billing/usage/logs/route.test.ts @@ -20,6 +20,10 @@ vi.mock('@/lib/billing/core/usage-log', () => ({ 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 = { diff --git a/apps/sim/app/api/v2/billing/usage/logs/route.ts b/apps/sim/app/api/v2/billing/usage/logs/route.ts index 531f87ea9ef..93621a9606b 100644 --- a/apps/sim/app/api/v2/billing/usage/logs/route.ts +++ b/apps/sim/app/api/v2/billing/usage/logs/route.ts @@ -13,6 +13,7 @@ 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, @@ -38,6 +39,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { 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, diff --git a/apps/sim/app/api/v2/billing/usage/route.test.ts b/apps/sim/app/api/v2/billing/usage/route.test.ts index da2bf7cb2f8..5e2a63f5adf 100644 --- a/apps/sim/app/api/v2/billing/usage/route.test.ts +++ b/apps/sim/app/api/v2/billing/usage/route.test.ts @@ -35,6 +35,10 @@ vi.mock('@/lib/billing/core/usage-log', () => ({ 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 = { diff --git a/apps/sim/app/api/v2/billing/usage/route.ts b/apps/sim/app/api/v2/billing/usage/route.ts index 1c17468ce73..60d826fc5c8 100644 --- a/apps/sim/app/api/v2/billing/usage/route.ts +++ b/apps/sim/app/api/v2/billing/usage/route.ts @@ -11,6 +11,7 @@ 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') @@ -32,6 +33,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { 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, diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts index 9d2e6d603b9..d168015d793 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -7,6 +7,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import type { V2ErrorCode } from '@/app/api/v2/lib/response' import { rateLimitHeaders, @@ -39,6 +40,10 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo 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, }) @@ -84,6 +89,10 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Fil 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, }) diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index dbc6e982068..47055ab713b 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -21,6 +21,7 @@ import { uploadWorkspaceFile, } from '@/lib/uploads/contexts/workspace' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { decodeCursor, encodeCursor, @@ -65,6 +66,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { 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, @@ -130,6 +135,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( v2UploadFileContract, request, diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts index 235c80707eb..41560fb7558 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts @@ -17,6 +17,7 @@ import { deleteDocument } from '@/lib/knowledge/documents/service' 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, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' const logger = createLogger('V2KnowledgeDocumentDetailAPI') @@ -59,6 +60,10 @@ export const GET = withRouteHandler( 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, }) @@ -151,6 +156,10 @@ export const DELETE = withRouteHandler( 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, }) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts index 2bb586afaeb..60103508d3d 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -32,6 +32,7 @@ import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' 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, @@ -84,6 +85,10 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Docume 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, }) @@ -161,6 +166,10 @@ export const POST = withRouteHandler( 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, }) diff --git a/apps/sim/app/api/v2/knowledge/[id]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/route.ts index 79bb1b4b86c..e6ae3849bae 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/route.ts @@ -14,6 +14,7 @@ import { deleteKnowledgeBase, updateKnowledgeBase } from '@/lib/knowledge/servic 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 { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' const logger = createLogger('V2KnowledgeDetailAPI') @@ -57,6 +58,10 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Knowle 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, }) @@ -90,6 +95,10 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: Knowle 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, }) @@ -154,6 +163,10 @@ export const DELETE = withRouteHandler( 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, }) diff --git a/apps/sim/app/api/v2/knowledge/route.ts b/apps/sim/app/api/v2/knowledge/route.ts index d1fb7d5b10d..65aae6b8d5a 100644 --- a/apps/sim/app/api/v2/knowledge/route.ts +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -13,6 +13,7 @@ import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowled import { createKnowledgeBase, getKnowledgeBases } from '@/lib/knowledge/service' import { formatKnowledgeBase } from '@/app/api/v1/knowledge/utils' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CursorList, v2Data, @@ -36,6 +37,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { 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, @@ -73,6 +78,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { 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, diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts index a5ac83a1360..005edb3b919 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -29,6 +29,7 @@ import { } 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, @@ -51,6 +52,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { 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, 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/logs/[id]/route.ts b/apps/sim/app/api/v2/logs/[id]/route.ts index 698e59f10ed..02593d73ec9 100644 --- a/apps/sim/app/api/v2/logs/[id]/route.ts +++ b/apps/sim/app/api/v2/logs/[id]/route.ts @@ -10,6 +10,7 @@ import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' 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') @@ -25,6 +26,10 @@ export const GET = withRouteHandler( 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, }) diff --git a/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts b/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts index da936577def..5b811960412 100644 --- a/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts +++ b/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts @@ -8,6 +8,7 @@ import { type V2Execution, v2GetExecutionContract } from '@/lib/api/contracts/v2 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') @@ -21,6 +22,10 @@ export const GET = withRouteHandler( 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, }) diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts index a4cc3372d37..c4e4077bdc5 100644 --- a/apps/sim/app/api/v2/logs/route.ts +++ b/apps/sim/app/api/v2/logs/route.ts @@ -12,6 +12,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' 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 { v2ApiGateError } from '@/app/api/v2/lib/gate' import { decodeCursor, encodeCursor, @@ -35,6 +36,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { 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, diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts index 75df47c8026..c7c1e538600 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts @@ -19,6 +19,7 @@ import { } from '@/lib/table' 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 { v2Data, v2Error, @@ -26,7 +27,7 @@ import { v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -import { v2TableAccessError, v2TablesGateError } from '@/app/api/v2/tables/utils' +import { v2TableAccessError } from '@/app/api/v2/tables/utils' const logger = createLogger('V2TableColumnsAPI') @@ -46,6 +47,10 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum 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, }) @@ -65,9 +70,6 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, validated.workspaceId) - if (gateError) return gateError - const updatedTable = await addTableColumn(tableId, validated.column, requestId) recordAudit({ @@ -111,6 +113,10 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu 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, }) @@ -130,9 +136,6 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, validated.workspaceId) - if (gateError) return gateError - const { updates } = validated let updatedTable = null @@ -217,6 +220,10 @@ export const DELETE = withRouteHandler( 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, }) @@ -236,9 +243,6 @@ export const DELETE = withRouteHandler( return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, validated.workspaceId) - if (gateError) return gateError - const updatedTable = await deleteColumn( { tableId, columnName: validated.columnName }, requestId 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 d692078122f..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 @@ -53,6 +53,11 @@ vi.mock('@/lib/workspaces/utils', () => ({ })) 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 = { @@ -117,41 +122,18 @@ describe('POST /api/v2/tables/[tableId]/query', () => { mockGetWorkspaceOrganizationId.mockResolvedValue('org-1') }) - it('returns 404 when the tables-v2-api flag is off', async () => { - mockIsFeatureEnabled.mockResolvedValue(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 callQuery({ workspaceId: 'workspace-1' }) + expect(res.status).toBe(404) expect((await res.json()).error.code).toBe('NOT_FOUND') expect(mockQueryRows).not.toHaveBeenCalled() }) - 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' }) - // Read path masks not-authorized as 404 so existence never leaks. - expect(res.status).toBe(404) - expect(mockIsFeatureEnabled).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 }, - ], - }) - }) - it('applies the bounded default limit when omitted', async () => { await callQuery({ workspaceId: 'workspace-1' }) expect(mockQueryRows.mock.calls[0][1].limit).toBe(100) 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 92d316594f9..495c8aeff48 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/query/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/query/route.ts @@ -16,6 +16,7 @@ import { queryRows } from '@/lib/table/rows/service' import { predicateToStorage } from '@/lib/table/select-values' 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, v2Error, @@ -23,7 +24,7 @@ import { v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -import { toApiRow, v2TablesGateError } from '@/app/api/v2/tables/utils' +import { toApiRow } from '@/app/api/v2/tables/utils' const logger = createLogger('V2TableQueryAPI') @@ -47,6 +48,10 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Query 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, @@ -68,9 +73,6 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Query return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, workspaceId) - if (gateError) return gateError - const schema = table.schema as TableSchema const cursor = cursorToken ? decodeCursor(cursorToken) : undefined diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts index 3f9809d2016..03e97d590fa 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -9,6 +9,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteTable } 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, @@ -16,7 +17,7 @@ import { v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -import { toApiTable, v2TableAccessError, v2TablesGateError } from '@/app/api/v2/tables/utils' +import { toApiTable, v2TableAccessError } from '@/app/api/v2/tables/utils' const logger = createLogger('V2TableDetailAPI') @@ -36,6 +37,10 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR 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, }) @@ -55,9 +60,6 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, workspaceId) - if (gateError) return gateError - return v2Data({ table: toApiTable(result.table) }, { rateLimit }) } catch (error) { logger.error(`[${requestId}] Error getting table`, { @@ -76,6 +78,10 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab 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, }) @@ -94,9 +100,6 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, workspaceId) - if (gateError) return gateError - await deleteTable(tableId, requestId) recordAudit({ 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 index 05397d4fed0..f11cf9b2c74 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts @@ -17,6 +17,7 @@ import { buildIdByName, rowDataNameToId, updateRow } 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 { v2Data, v2Error, @@ -24,7 +25,7 @@ import { v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -import { toApiRow, v2TableAccessError, v2TablesGateError } from '@/app/api/v2/tables/utils' +import { toApiRow, v2TableAccessError } from '@/app/api/v2/tables/utils' const logger = createLogger('V2TableRowAPI') @@ -44,6 +45,10 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RowRou 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, }) @@ -63,9 +68,6 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RowRou return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, workspaceId) - if (gateError) return gateError - const [row] = await db .select({ id: userTableRows.id, @@ -117,6 +119,10 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR 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, }) @@ -136,9 +142,6 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, validated.workspaceId) - if (gateError) return gateError - const idByName = buildIdByName(table.schema as TableSchema) const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) const updatedRow = await updateRow( @@ -189,6 +192,10 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row 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, }) @@ -207,9 +214,6 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, workspaceId) - if (gateError) return gateError - const [deletedRow] = await db .delete(userTableRows) .where( diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts index 2df533f2855..a2677af979c 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts @@ -33,6 +33,7 @@ import { type RateLimitResult, resolveWorkspaceScope, } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { decodeCursor, encodeCursor, @@ -49,7 +50,6 @@ import { v2RowValidationError, v2RowWriteError, v2TableAccessError, - v2TablesGateError, } from '@/app/api/v2/tables/utils' const logger = createLogger('V2TableRowsAPI') @@ -81,9 +81,6 @@ async function handleBatchInsert( return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, validated.workspaceId) - if (gateError) return gateError - // 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) @@ -133,6 +130,10 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR 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, }) @@ -153,9 +154,6 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, validated.workspaceId) - if (gateError) return gateError - const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) // Cursor-uniform v2 pagination: the opaque cursor encodes the underlying @@ -206,6 +204,10 @@ export const POST = withRouteHandler( 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, }) @@ -232,9 +234,6 @@ export const POST = withRouteHandler( return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, validated.workspaceId) - if (gateError) return gateError - const idByName = buildIdByName(table.schema as TableSchema) const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) const rowData = rowDataNameToId(validated.data as RowData, idByName) @@ -276,6 +275,10 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR 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, }) @@ -295,9 +298,6 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, validated.workspaceId) - if (gateError) return gateError - const idByName = buildIdByName(table.schema as TableSchema) const patchData = rowDataNameToId(validated.data as RowData, idByName) @@ -347,6 +347,10 @@ export const DELETE = withRouteHandler( 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, }) @@ -366,9 +370,6 @@ export const DELETE = withRouteHandler( return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, validated.workspaceId) - if (gateError) return gateError - // 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. 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 index cab5e42538e..08f4b0873af 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts @@ -10,6 +10,7 @@ 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 { v2Data, v2Error, @@ -17,7 +18,7 @@ import { v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -import { toApiRow, v2TableAccessError, v2TablesGateError } from '@/app/api/v2/tables/utils' +import { toApiRow, v2TableAccessError } from '@/app/api/v2/tables/utils' const logger = createLogger('V2TableUpsertAPI') @@ -37,6 +38,10 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser 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, }) @@ -56,9 +61,6 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, validated.workspaceId) - if (gateError) return gateError - const idByName = buildIdByName(table.schema as TableSchema) const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) const upsertResult = await upsertRow( diff --git a/apps/sim/app/api/v2/tables/route.test.ts b/apps/sim/app/api/v2/tables/route.test.ts index 8c290804b5e..af7a12f403a 100644 --- a/apps/sim/app/api/v2/tables/route.test.ts +++ b/apps/sim/app/api/v2/tables/route.test.ts @@ -46,6 +46,10 @@ 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' const RATE_LIMIT_OK = { @@ -89,43 +93,18 @@ describe('GET /api/v2/tables', () => { mockGetWorkspaceOrganizationId.mockResolvedValue('org-1') }) - it('returns 404 when the tables-v2-api flag is off', async () => { - mockIsFeatureEnabled.mockResolvedValue(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 callList('workspaceId=workspace-1') + expect(res.status).toBe(404) expect((await res.json()).error.code).toBe('NOT_FOUND') expect(mockListTables).not.toHaveBeenCalled() }) - it('runs the flag gate only after the access check, so it cannot leak a cohort oracle', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - const res = await callList('workspaceId=workspace-1') - expect(res.status).toBe(403) - expect(mockIsFeatureEnabled).not.toHaveBeenCalled() - }) - - it('returns typed table summaries in the cursor envelope with a private cache header', async () => { - const res = await callList('workspaceId=workspace-1') - expect(res.status).toBe(200) - expect(res.headers.get('Cache-Control')).toBe('private, no-store') - const body = await res.json() - expect(body.nextCursor).toBeNull() - expect(body.data).toHaveLength(1) - expect(body.data[0]).toMatchObject({ - id: 'tbl_1', - name: 'People', - description: 'A table', - rowCount: 5, - maxRows: 100, - createdAt: '2024-01-01T00:00:00.000Z', - }) - expect(body.data[0].schema.columns[0].name).toBe('name') - }) - it('400s when workspaceId is missing', async () => { const res = await callList('') expect(res.status).toBe(400) diff --git a/apps/sim/app/api/v2/tables/route.ts b/apps/sim/app/api/v2/tables/route.ts index 5e3dec6bf66..9082e9280b9 100644 --- a/apps/sim/app/api/v2/tables/route.ts +++ b/apps/sim/app/api/v2/tables/route.ts @@ -9,6 +9,7 @@ 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 { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CursorList, v2Data, @@ -17,7 +18,7 @@ import { v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -import { toApiTable, v2TablesGateError } from '@/app/api/v2/tables/utils' +import { toApiTable } from '@/app/api/v2/tables/utils' const logger = createLogger('V2TablesAPI') @@ -33,6 +34,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( v2ListTablesContract, request, @@ -48,9 +53,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const gateError = await v2TablesGateError(userId, workspaceId) - if (gateError) return gateError - const tables = await listTables(workspaceId) const items = tables.map(toApiTable) @@ -73,6 +75,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( v2CreateTableContract, request, @@ -88,9 +94,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) - const gateError = await v2TablesGateError(userId, params.workspaceId) - if (gateError) return gateError - const planLimits = await getWorkspaceTableLimits(params.workspaceId) const normalizedSchema: TableSchema = { diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts index c6418d240a0..00a9510feb8 100644 --- a/apps/sim/app/api/v2/tables/utils.ts +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -1,5 +1,4 @@ import type { NextResponse } from 'next/server' -import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import type { RowData, TableDefinition, TablePredicate, TableSchema } from '@/lib/table' import { predicateToFilter } from '@/lib/table/query-builder/converters' import { @@ -8,7 +7,6 @@ import { } from '@/lib/table/query-builder/validate' import { predicateToStorage } from '@/lib/table/select-values' import type { Filter } from '@/lib/table/types' -import { getWorkspaceOrganizationId } from '@/lib/workspaces/utils' import { normalizeColumn, rootErrorMessage, rowWriteErrorResponse } from '@/app/api/table/utils' import { v2Error } from '@/app/api/v2/lib/response' @@ -25,23 +23,6 @@ function toIso(value: Date | string): string { return value instanceof Date ? value.toISOString() : String(value) } -/** - * Rollout gate for the whole v2 tables surface (`tables-v2-api` flag). - * - * **Call this AFTER the authz check, never before.** Ahead of authz it does a - * primary-DB read keyed on a caller-supplied `workspaceId`, and the 404-vs-403 - * split tells an unauthorized caller whether that workspace's org is in the - * rollout cohort. - */ -export async function v2TablesGateError( - userId: string, - workspaceId: string -): Promise { - const orgId = await getWorkspaceOrganizationId(workspaceId) - if (await isFeatureEnabled('tables-v2-api', { userId, orgId })) return null - return v2Error('NOT_FOUND', 'Not found') -} - /** * 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 diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts index b861215d388..ec2479d09b8 100644 --- a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts @@ -14,6 +14,7 @@ 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') @@ -31,6 +32,10 @@ export const POST = withRouteHandler( 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, }) @@ -114,6 +119,10 @@ export const DELETE = withRouteHandler( 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, }) 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 index 4f70c4942f5..757b09e7388 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts @@ -132,6 +132,10 @@ vi.mock('@sim/utils/id', () => ({ ), })) +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + import { attachExecutionResult } from '@/executor/utils/errors' import { POST } from './route' @@ -268,6 +272,18 @@ describe('POST /api/v2/workflows/[id]/execute', () => { ) }) + 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' }) diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts index 5cc786a2340..ebe26b9ac2e 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts @@ -25,6 +25,7 @@ import { } 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, @@ -134,6 +135,9 @@ export const POST = withRouteHandler( 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.', { 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 index 6f7bc473a22..c0e96fc8080 100644 --- 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 @@ -36,6 +36,10 @@ 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' diff --git a/apps/sim/app/api/v2/workflows/[id]/export/route.ts b/apps/sim/app/api/v2/workflows/[id]/export/route.ts index e8aa31c0710..35ab0d287d6 100644 --- a/apps/sim/app/api/v2/workflows/[id]/export/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/export/route.ts @@ -9,6 +9,7 @@ import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildWorkflowExportPayload } from '@/lib/workflows/operations/export-workflow' 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('V2WorkflowExportAPI') @@ -34,6 +35,10 @@ export const GET = withRouteHandler( 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, }) diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts index b2d2d1d2a92..8d1cea75788 100644 --- a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts @@ -12,6 +12,7 @@ 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') @@ -29,6 +30,10 @@ export const POST = withRouteHandler( 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, }) diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts index a059d669648..7698187bb7a 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -11,6 +11,7 @@ import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' 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('V2WorkflowDetailAPI') @@ -26,6 +27,10 @@ export const GET = withRouteHandler( 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, }) diff --git a/apps/sim/app/api/v2/workflows/import/route.ts b/apps/sim/app/api/v2/workflows/import/route.ts index 65ae7a3dc22..af66621b16c 100644 --- a/apps/sim/app/api/v2/workflows/import/route.ts +++ b/apps/sim/app/api/v2/workflows/import/route.ts @@ -10,6 +10,7 @@ import { MAX_IMPORT_BODY_BYTES, } from '@/lib/workflows/operations/import-workflow' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { type V2ErrorCode, v2Data, @@ -48,6 +49,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { 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, diff --git a/apps/sim/app/api/v2/workflows/lib/access.ts b/apps/sim/app/api/v2/workflows/lib/access.ts index 765933793ee..404b820ac89 100644 --- a/apps/sim/app/api/v2/workflows/lib/access.ts +++ b/apps/sim/app/api/v2/workflows/lib/access.ts @@ -3,6 +3,7 @@ import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/work 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 @@ -32,6 +33,9 @@ export async function resolveV2WorkflowAccess( 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, diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts index a35f045bda7..ffe19c9ebf1 100644 --- a/apps/sim/app/api/v2/workflows/route.ts +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -9,6 +9,7 @@ import { type V2WorkflowListItem, v2ListWorkflowsContract } from '@/lib/api/cont 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 { decodeCursor, encodeCursor, @@ -39,6 +40,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { 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, 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 9dff329a647..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 @@ -12,7 +12,6 @@ import { Tooltip, } from '@sim/emcn' import { Check, Clipboard } from 'lucide-react' -import { getBaseUrl } from '@/lib/core/utils/urls' import { AGENT_STREAM_PROTOCOL_HEADER_LABEL, AGENT_STREAM_PROTOCOL_V1, @@ -25,6 +24,7 @@ interface WorkflowDeploymentInfo { deployedAt?: string apiKey: string endpoint: string + exampleCommand: string needsRedeployment: boolean isPublicApi?: boolean } @@ -39,7 +39,7 @@ interface ApiDeployProps { onSelectedStreamingOutputsChange: (outputs: string[]) => void } -type AsyncExampleType = 'execute' | 'status' | 'usage' +type AsyncExampleType = 'execute' | 'status' | 'rate-limits' type CodeLanguage = 'curl' | 'python' | 'javascript' | 'typescript' type CopiedState = { @@ -97,23 +97,19 @@ export function ApiDeploy({ return info.endpoint.replace(info.apiKey, '$SIM_API_KEY') } - /** The workflow's example input fields, parsed from the shared example command. */ - const getInputObject = (): Record => { + const getPayloadObject = (): Record => { const inputExample = getInputFormatExample ? getInputFormatExample(false) : '' const match = inputExample.match(/-d\s*'([\s\S]*)'/) if (match) { try { return JSON.parse(match[1]) as Record } catch { - return { key: 'value' } + return { input: 'your data here' } } } - return { key: 'value' } + return { input: 'your data here' } } - /** v2 body: the input nests under `input`; control fields are siblings. */ - const getPayloadObject = (): Record => ({ input: getInputObject() }) - const getStreamPayloadObject = (): Record => { const payload: Record = { ...getPayloadObject(), stream: true } if (selectedStreamingOutputs && selectedStreamingOutputs.length > 0) { @@ -152,7 +148,7 @@ ${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')} ) -print(response.json()["data"])` +print(response.json())` case 'javascript': return `const response = await fetch("${endpoint}", { @@ -163,7 +159,7 @@ ${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Typ body: JSON.stringify(${JSON.stringify(payload)}) }); -const { data } = await response.json(); +const data = await response.json(); console.log(data);` case 'typescript': @@ -175,7 +171,7 @@ ${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Typ body: JSON.stringify(${JSON.stringify(payload)}) }); -const { data }: { data: Record } = await response.json(); +const data: Record = await response.json(); console.log(data);` default: @@ -265,8 +261,8 @@ while (true) { const getAsyncCommand = (): string => { if (!info) return '' const endpoint = getBaseEndpoint() - const baseUrl = getBaseUrl() - const payload = { ...getPayloadObject(), async: true } + const baseUrl = endpoint.split('/api/workflows/')[0] + const payload = getPayloadObject() const isPublic = info.isPublicApi switch (asyncExampleType) { @@ -275,6 +271,7 @@ while (true) { case 'curl': return `curl -X POST \\ ${isPublic ? '' : ' -H "X-API-Key: $SIM_API_KEY" \\\n'} -H "Content-Type: application/json" \\ + -H "X-Execution-Mode: async" \\ -d '${JSON.stringify(payload)}' \\ ${endpoint}` @@ -285,38 +282,40 @@ import requests response = requests.post( "${endpoint}", headers={ -${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} "Content-Type": "application/json" +${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} "Content-Type": "application/json", + "X-Execution-Mode": "async" }, json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')} ) -job = response.json()["data"] -print(job) # Contains executionId and statusUrl` +job = response.json() +print(job) # Contains jobId and executionId` case 'javascript': return `const response = await fetch("${endpoint}", { method: "POST", headers: { -${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json" +${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json", + "X-Execution-Mode": "async" }, body: JSON.stringify(${JSON.stringify(payload)}) }); -const { data: job } = await response.json(); -console.log(job); // Contains executionId and statusUrl` +const job = await response.json(); +console.log(job); // Contains jobId and executionId` case 'typescript': return `const response = await fetch("${endpoint}", { method: "POST", headers: { -${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json" +${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json", + "X-Execution-Mode": "async" }, body: JSON.stringify(${JSON.stringify(payload)}) }); -const { data: job }: { data: { executionId: string; statusUrl: string } } = - await response.json(); -console.log(job); // Poll statusUrl until status is terminal` +const job: { jobId: string; executionId: string } = await response.json(); +console.log(job); // Contains jobId and executionId` default: return '' @@ -326,84 +325,84 @@ console.log(job); // Poll statusUrl until status is terminal` switch (language) { case 'curl': return `curl -H "X-API-Key: $SIM_API_KEY" \\ - ${baseUrl}/api/v2/workflows/${workflowId}/executions/EXECUTION_ID` + ${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION` case 'python': return `import os import requests response = requests.get( - "${baseUrl}/api/v2/workflows/${workflowId}/executions/EXECUTION_ID", + "${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION", headers={"X-API-Key": os.environ.get("SIM_API_KEY")} ) -status = response.json()["data"] -print(status) # status: queued | running | completed | failed | cancelled | paused` +status = response.json() +print(status)` case 'javascript': return `const response = await fetch( - "${baseUrl}/api/v2/workflows/${workflowId}/executions/EXECUTION_ID", + "${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION", { headers: { "X-API-Key": process.env.SIM_API_KEY } } ); -const { data: status } = await response.json(); +const status = await response.json(); console.log(status);` case 'typescript': return `const response = await fetch( - "${baseUrl}/api/v2/workflows/${workflowId}/executions/EXECUTION_ID", + "${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION", { headers: { "X-API-Key": process.env.SIM_API_KEY } } ); -const { data: status }: { data: Record } = await response.json(); +const status: Record = await response.json(); console.log(status);` default: return '' } - case 'usage': + case 'rate-limits': switch (language) { case 'curl': return `curl -H "X-API-Key: $SIM_API_KEY" \\ - ${baseUrl}/api/v2/billing/usage` + ${baseUrl}/api/users/me/usage-limits` case 'python': return `import os import requests response = requests.get( - "${baseUrl}/api/v2/billing/usage", + "${baseUrl}/api/users/me/usage-limits", headers={"X-API-Key": os.environ.get("SIM_API_KEY")} ) -limits = response.json()["data"] -print(limits) # totalCredits + bySourceCredits breakdown` +limits = response.json() +print(limits)` case 'javascript': return `const response = await fetch( - "${baseUrl}/api/v2/billing/usage", + "${baseUrl}/api/users/me/usage-limits", { headers: { "X-API-Key": process.env.SIM_API_KEY } } ); -const { data: limits } = await response.json(); +const limits = await response.json(); console.log(limits);` case 'typescript': return `const response = await fetch( - "${baseUrl}/api/v2/billing/usage", + "${baseUrl}/api/users/me/usage-limits", { headers: { "X-API-Key": process.env.SIM_API_KEY } } ); -const { data: limits }: { data: Record } = await response.json(); +const limits: Record = await response.json(); console.log(limits);` default: @@ -415,6 +414,19 @@ console.log(limits);` } } + const getAsyncExampleTitle = () => { + switch (asyncExampleType) { + case 'execute': + return 'Execute Job' + case 'status': + return 'Check Status' + case 'rate-limits': + return 'Usage Limits' + default: + return 'Execute Job' + } + } + const handleCopy = (key: keyof CopiedState, value: string) => { navigator.clipboard.writeText(value) setCopied((prev) => ({ ...prev, [key]: true })) @@ -552,7 +564,7 @@ console.log(limits);` options={[ { label: 'Execute Job', value: 'execute' }, { label: 'Check Status', value: 'status' }, - { label: 'Usage', value: 'usage' }, + { label: 'Usage Limits', value: 'rate-limits' }, ]} value={asyncExampleType} onChange={(value) => setAsyncExampleType(value as AsyncExampleType)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index 91bb280288e..aa33d96cc92 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -77,6 +77,7 @@ interface WorkflowDeploymentInfoUI { deployedAt?: string apiKey: string endpoint: string + exampleCommand: string needsRedeployment: boolean isPublicApi: boolean } @@ -233,7 +234,7 @@ export function DeployModal({ return null } - const endpoint = `${getBaseUrl()}/api/v2/workflows/${workflowId}/execute` + const endpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute` const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) const placeholderKey = getApiHeaderPlaceholder() @@ -242,6 +243,7 @@ export function DeployModal({ deployedAt: deploymentInfoData.deployedAt ?? undefined, apiKey: getApiKeyLabel(deploymentInfoData.apiKey), endpoint, + exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`, needsRedeployment: deploymentInfoData.needsRedeployment, isPublicApi: isPublicApiDisabled ? false : (deploymentInfoData.isPublicApi ?? false), } diff --git a/apps/sim/blocks/blocks/api_trigger.ts b/apps/sim/blocks/blocks/api_trigger.ts index 9c264b78fd9..27ad2beef33 100644 --- a/apps/sim/blocks/blocks/api_trigger.ts +++ b/apps/sim/blocks/blocks/api_trigger.ts @@ -11,7 +11,7 @@ export const ApiTriggerBlock: BlockConfig = { bestPractices: ` - Can run the workflow manually to test implementation when this is the trigger point. - The input format determines variables accesssible in the following blocks. E.g. . You can set the value in the input format to test the workflow manually. - - In production, the curl would come in as e.g. curl -X POST -H "X-API-Key: $SIM_API_KEY" -H "Content-Type: application/json" -d '{"input":{"paramName":"example"}}' https://www.sim.ai/api/v2/workflows/9e7e4f26-fc5e-4659-b270-7ea474b14f4a/execute -- If user asks to test via API, you might need to clarify the API key. + - In production, the curl would come in as e.g. curl -X POST -H "X-API-Key: $SIM_API_KEY" -H "Content-Type: application/json" -d '{"paramName":"example"}' https://www.staging.sim.ai/api/workflows/9e7e4f26-fc5e-4659-b270-7ea474b14f4a/execute -- If user asks to test via API, you might need to clarify the API key. `, category: 'triggers', hideFromToolbar: true, diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index 3ebc74b6fee..f0d14b9ba29 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -30,7 +30,7 @@ import { ensureWorkflowAccess } from '../access' import type { DeployApiParams, DeployChatParams, DeployMcpParams } from '../param-types' function buildWorkflowApiEndpoint(baseUrl: string, workflowId: string): string { - return `${baseUrl}/api/v2/workflows/${workflowId}/execute` + return `${baseUrl}/api/workflows/${workflowId}/execute` } function buildWorkflowApiConfig(baseUrl: string, apiEndpoint: string) { @@ -57,8 +57,9 @@ function buildWorkflowApiConfig(baseUrl: string, apiEndpoint: string) { method: 'POST', transport: 'json', stream: false, - body: { async: true, input: { key: 'value' } }, - jobStatusEndpointTemplate: `${baseUrl}/api/v2/workflows/{workflowId}/executions/{executionId}`, + headers: { 'X-Execution-Mode': 'async' }, + body: { input: { key: 'value' } }, + jobStatusEndpointTemplate: `${baseUrl}/api/jobs/{jobId}`, }, }, } @@ -77,8 +78,9 @@ function buildWorkflowApiExamples(baseUrl: string, apiEndpoint: string) { async: `curl -X POST "${apiEndpoint}" \\ -H "Content-Type: application/json" \\ -H "X-API-Key: YOUR_API_KEY" \\ - -d '{"async":true,"input":{"key":"value"}}'`, - poll: `curl "${baseUrl}/api/v2/workflows/WORKFLOW_ID/executions/EXECUTION_ID" \\ + -H "X-Execution-Mode: async" \\ + -d '{"input":{"key":"value"}}'`, + poll: `curl "${baseUrl}/api/jobs/JOB_ID" \\ -H "X-API-Key: YOUR_API_KEY"`, } } diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index a649f6c7112..ff61d1762d1 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -82,7 +82,7 @@ export async function executeCheckDeploymentStatus( const apiDetails = { isDeployed: isApiDeployed, deployedAt: apiDeploy[0]?.deployedAt || null, - endpoint: isApiDeployed ? `/api/v2/workflows/${workflowId}/execute` : null, + endpoint: isApiDeployed ? `/api/workflows/${workflowId}/execute` : null, apiKey: workflowRecord.workspaceId ? 'Workspace API keys' : 'Personal API keys', needsRedeployment, activeDeployment: deploymentSummary.activeDeployment, diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 789c1044eb0..d394ed07347 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -808,7 +808,7 @@ export function serializeDeployments(data: DeploymentData): string { result.api = { isDeployed: true, deployedAt: data.deployedAt?.toISOString(), - apiEndpoint: `/api/v2/workflows/${data.workflowId}/execute`, + apiEndpoint: `/api/workflows/${data.workflowId}/execute`, ...(data.api ? { version: data.api.version } : {}), } } diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 8c28ba643f8..5877b11e197 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -481,6 +481,7 @@ export const env = createEnv({ SESSION_POLICIES_ENABLED: z.boolean().optional(), // Enable org session policies on self-hosted (bypasses hosted requirements) FORKING_ENABLED: z.boolean().optional(), // Enable workspace forking on self-hosted (bypasses hosted requirements) DEPLOY_AS_BLOCK: z.boolean().optional(), // Enable deploy-as-block (publish a workflow as a reusable org-wide custom block) + V2_API: z.boolean().optional(), // Enable the /api/v2 HTTP surface (all v2 routes 404 when off) TABLES_V2_API: z.boolean().optional(), // Enable the v2 tables HTTP API (public /api/v2/tables + internal /api/table/[tableId]/query predicate-grammar route) TABLE_LOCKS: z.boolean().optional(), // Enable per-table mutation locks (schema/insert/update/delete toggles) TABLE_VIEWS: z.boolean().optional(), // Enable saved table views (named filter/sort/column-visibility presets) and the column show/hide menu diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 2989f04bcf3..22c64e1708b 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -104,6 +104,15 @@ const FEATURE_FLAGS = { 'custom-block publish/list routes. Off-AppConfig falls back to DEPLOY_AS_BLOCK.', fallback: 'DEPLOY_AS_BLOCK', }, + 'v2-api': { + description: + 'Gate the whole /api/v2 HTTP surface (workflows incl. execute/executions, tables, logs, ' + + 'knowledge, files, audit-logs, billing). One check per request, immediately after auth: ' + + 'when off, every v2 route returns 404 as if the surface does not exist. The gate is keyed ' + + 'on userId only — it never reads workspace/org membership, so an ungated caller learns ' + + 'nothing beyond "no such route". Off-AppConfig falls back to V2_API.', + fallback: 'V2_API', + }, 'tables-v2-api': { description: 'Gate the v2 tables HTTP API — the public read API (GET /api/v2/tables, POST ' + From fbc7b177b7271f6a4308a9cd8c8227e4a6866c1c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 16:01:22 -0700 Subject: [PATCH 16/24] fix(executor): restore child-cost aggregation dropped by the staging merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staging's custom-block rewrite deleted `aggregateChildCost` from workflow-handler.ts, and git merged that file cleanly — but this branch's workflow-tool-runner.ts, added for the v2 execute migration, still imports it. A silent semantic conflict: no marker, broken build. Taking staging's rewrite is correct, so the helper is defined locally in its one remaining consumer rather than resurrected in the file staging just rewrote. Same four lines over the still-exported `calculateCostSummary`, so a failed child workflow keeps billing the hosted-key spend it consumed instead of reporting $0. Co-Authored-By: Claude Opus 5 --- .../handlers/workflow/workflow-tool-runner.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts b/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts index e4905adacb1..9596b039cca 100644 --- a/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts +++ b/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts @@ -1,15 +1,14 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { calculateCostSummary } from '@/lib/logs/execution/logging-factory' +import type { TraceSpan } from '@/lib/logs/types' import { ChildWorkflowError } from '@/executor/errors/child-workflow-error' import { buildCustomBlockExecutionContext, type CustomBlockExecutorContext, } from '@/executor/handlers/workflow/custom-block-tool-runner' -import { - aggregateChildCost, - WorkflowBlockHandler, -} from '@/executor/handlers/workflow/workflow-handler' +import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler' import { classifyExecutionError } from '@/executor/utils/errors' import { parseJSON } from '@/executor/utils/json' import type { SerializedBlock } from '@/serializer/types' @@ -17,6 +16,18 @@ import type { ToolResponse } from '@/tools/types' const logger = createLogger('WorkflowToolRunner') +/** + * Hosted-key spend of a failed child run, the way the parent bills it: recurse + * nested spans and de-dupe model breakdowns, then subtract the base execution + * charge the parent already applies once itself. A naive top-level `cost.total` + * sum undercounts when spend sits on nested children. + */ +function aggregateChildCost(childTraceSpans: TraceSpan[]): number { + if (childTraceSpans.length === 0) return 0 + const summary = calculateCostSummary(childTraceSpans) + return Math.max(0, summary.totalCost - summary.baseExecutionCharge) +} + interface WorkflowToolParams { workflowId?: string inputMapping?: Record | string From 5fea5f7bc69b190714b61ee773209ff7862394b9 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 18:24:49 -0700 Subject: [PATCH 17/24] refactor(tables): make lib/table/orchestration the single implementation (#6134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(orchestration): move the shared error contract out of lib/workflows OrchestrationErrorCode and statusForOrchestrationError are the contract every lib/[resource]/orchestration module returns against, but they lived inside the workflows module, so resource-neutral code (lib/folders) already had to import from a workflow path. Moved to lib/core/orchestration/types. Adds a 'locked' class mapping to 423. Both tables and workflows have a lock that forbids a mutation, and each caller was translating that to a status itself. Co-Authored-By: Claude Opus 5 * refactor(tables): make lib/table/orchestration the single implementation Column update was implemented four times — the UI route, v1, v2, and the copilot table tool — each calling the same column services but owning its own guards, error mapping, and audit. The copies had drifted, and the drift was the bug: v2 was missing both guards, only the copilot copy minted stable option ids, and only v1/v2 audited. performUpdateTableColumn, performDeleteTable, and performDeleteTableRow now own that logic; all ten call sites reduce to auth, parse, call, render. The guards are asserted once in lib/table/orchestration rather than four times against four routes. Behavior this consolidates, previously true on only some paths: - The typeChanging guard. updateColumnType early-returns on an unchanged type and drops any options sent with it, so restating the current type alongside new options silently discarded them. v2 had no guard at all and, since its contract shares v1's body schema, accepted options and ignored them. - The select-unique guard. Each write is its own locked transaction, so a rename or type change paired with a constraint write that is going to fail commits first and then throws, half-applying the schema change. - Stable select-option ids. Cells reference the option id, so an edit that re-sends an option by name has to reuse it or every cell holding it is orphaned. Only the copilot path did this; normalizeSelectOptionsInput moves to lib/table/select-options and now covers every caller. It preserves a supplied id, so it is a no-op for the fully-formed options the HTTP contracts accept. - required forwarded into the type and options writes, so a conversion validates against the constraint the same request is setting. - An audit on every successful update. The UI route and the copilot tool emitted none. - Single-row delete through the row service. v2 did a raw db.delete, skipping assertRowDelete and deleteOrderedRow, so a delete-locked table returned 200 and the row-count bookkeeping never ran. - The delete actor handed to deleteTable, which audits only when a row was actually archived. v1 and v2 omitted it and audited themselves outside that check, emitting TABLE_DELETED for a no-op delete of an archived table. Failure classes come back as OrchestrationErrorCode; v2 renders them through a new v2ErrorForOrchestration, mirroring statusForOrchestrationError on the v1 and UI surfaces, so a given failure maps to the same status everywhere. Co-Authored-By: Claude Opus 5 * test(tables): bind the column-update tests to the orchestration function The base's route tests assert which column service each payload reaches — the behavior that now lives in performUpdateTableColumn. They mocked the `@/lib/table` barrel; the orchestration module imports the service directly, so they mock that too and keep asserting the same thing through the extracted implementation. The orchestration tests move onto the base's semantics: writes address the stable column id, a rename rides inside the write it accompanies rather than running first, and the currency guards replace the non-select options guard the service now owns. Co-Authored-By: Claude Opus 5 * chore(copilot): drop the column-type import the delegation made dead Co-Authored-By: Claude Opus 5 * refactor(tables): move the audit log out of the table service `lib/table/service.ts` wrote its own audit rows, so whether an operation was audited depended on which function a caller reached for rather than on a user having performed it. That is what let v1 and v2 audit a no-op delete, and what made `deleteTable`'s optional `actingUserId` double as an audit opt-out flag. Worse, most sites fell back to `actingUserId ?? createdBy`, so an unattributed call was logged against the table's *creator*. The copilot `mv` path passed no actor at all: renaming someone else's table recorded them as the renamer. Audit now lives in the orchestration functions — performDeleteTable, performRenameTable, performMoveTableToFolder, performUpdateTableLocks — and the services just write. Internal callers (folder cascade, import rollback) keep calling the service and are silent by construction rather than by remembering to omit an argument. Two services now return what the audit needs: `deleteTable` reports whether it actually archived a row, so a repeat delete logs nothing; `updateTableLocks` returns the before/after locks, since only the locked write can observe the transition its description names. Co-Authored-By: Claude Opus 5 * fix(tables): restore audit provenance and conflict status in orchestration Moving the audits into the orchestration functions dropped three things the routes had been carrying, and added one the orchestration now owns twice. - The v1 and v2 column-update routes passed `request` to `recordAudit`, so their audit rows recorded the caller's IP and user-agent. The orchestration function had no way to receive it. Every table orchestration function now takes an optional `OrchestrationRequestContext` and every HTTP route forwards it; the copilot and VFS callers, which have no request, omit it. - `classifyTableMutation` matched `TableConflictError` on "already exists" appearing in the message and reported it as `validation`, turning the UI route's 409 on a duplicate table rename into a 400. It now matches the type, the way `performRestoreTable` already did. - `captureServerEvent` ran on every delete while the audit was gated on a row actually being archived, so a repeat delete of an archived table still reported `table_deleted`. Both now hang off the same evidence. - The copilot delete path kept its own `captureServerEvent` from when the service did not emit one, double-counting every copilot table delete. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YGzbVDZpe2dEALbu2BUU8a * fix(tables): say which type a no-op column update restated A copilot `update_column` payload whose only content was the column's current type used to return success with the live schema, while the v1, v2, and UI routes rejected the same payload with "No updates specified". Delegating to `performUpdateTableColumn` unified them onto the routes' rejection — correct, but the message tells the caller its request was empty when it named a type. The orchestration function now reports the same thing `updateColumnType` reports when it loses this race concurrently: the column is already that type, re-issue without the type change. An empty payload still reads "No updates specified". Drops the copilot's `outcome.table ?? tableForUpdate` fallback with it — the comment described the no-op that can no longer reach that line, and a success always carries a table. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YGzbVDZpe2dEALbu2BUU8a * refactor(tables): classify failures by type instead of by message text The table module decided HTTP statuses by searching error messages for phrases. `VALIDATION_MESSAGE_FRAGMENTS` and `ROW_WRITE_ERROR_PATTERNS` held 32 substrings between them, and fifteen more lists were inlined in routes — 83 matchers over 17 files, each its own copy of the guesswork and already drifted apart. It made message wording load-bearing: `TableRowLimitError`'s own doc comment noted that its text had to contain "row limit" for a route to answer 400, and adding "already exists" to a rename message silently demoted a 409 to a 400 (the bug fixed one commit ago, by adding another special case). Services now throw `OrchestrationError`, which carries the transport-neutral `OrchestrationErrorCode` the layers above already speak. Classification is one `instanceof` in `orchestrationErrorResponse` (UI + v1) and `v2CaughtOrchestrationError` (v2). Every pattern list is gone. Wording is free to change; an unclassified error still becomes a generic 500, which is what an unexpected fault should be. `asOrchestrationError` walks the `cause` chain rather than testing the caught value directly: drizzle wraps a throw raised inside a transaction callback in a `DrizzleQueryError` whose own message is the failed SQL, so a bare `instanceof` would drop every failure raised inside `withLockedTable`. That is the same reason `rootErrorMessage` had to dig for a root cause before. Three throws stay bare `Error` deliberately — `Table ID mismatch`, `Workspace ID mismatch`, and `Failed to build upsert conflict predicate` are internal invariants no consumer classified, and they keep falling through to a 500. `Insufficient capacity` was in the pattern list with no producer anywhere in the codebase. Status changes, all deliberate: - `'forbidden'` joins the code union so the table-row-limit ceiling keeps its 403; without it this refactor would have flattened it to 400. - import-async's table-limit rejection: 400 -> 403, matching the two other create routes it had drifted from. - Renaming a table to an invalid name: 500 -> 400. `validateTableName` messages don't contain "Invalid", so no matcher ever caught them. - Restoring a table that isn't archived, or into an archived workspace: 500 -> 400. - A duplicate *column* name stays `validation`/400 rather than becoming a 409 like a duplicate table name. Both v1 and the orchestration have always answered 400 for it; changing a published status is not this refactor's job. The twelve tests that changed were asserting the substring mechanism itself, constructing plain `Error`s with magic strings. They now assert the real contract, plus new cases pinning that identical wording carrying no classification stays internal and keeps its message off the wire. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YGzbVDZpe2dEALbu2BUU8a --------- Co-authored-by: Claude Opus 5 --- .../api/table/[tableId]/columns/route.test.ts | 13 +- .../app/api/table/[tableId]/columns/route.ts | 222 +------------- .../api/table/[tableId]/columns/run/route.ts | 12 +- .../api/table/[tableId]/import/route.test.ts | 14 +- .../app/api/table/[tableId]/import/route.ts | 43 +-- .../sim/app/api/table/[tableId]/route.test.ts | 20 +- apps/sim/app/api/table/[tableId]/route.ts | 95 +++--- .../api/table/[tableId]/rows/[rowId]/route.ts | 26 +- apps/sim/app/api/table/import-async/route.ts | 10 +- .../app/api/table/import-csv/route.test.ts | 21 +- apps/sim/app/api/table/import-csv/route.ts | 23 +- apps/sim/app/api/table/route.ts | 16 +- apps/sim/app/api/table/utils.test.ts | 24 +- apps/sim/app/api/table/utils.ts | 64 ++-- .../app/api/tools/deployments/deploy/route.ts | 2 +- .../api/tools/deployments/promote/route.ts | 2 +- .../api/v1/tables/[tableId]/columns/route.ts | 264 ++--------------- apps/sim/app/api/v1/tables/[tableId]/route.ts | 24 +- .../v1/tables/[tableId]/rows/[rowId]/route.ts | 44 ++- .../v1/tables/[tableId]/rows/upsert/route.ts | 23 +- apps/sim/app/api/v1/tables/route.ts | 16 +- .../app/api/v1/workflows/[id]/deploy/route.ts | 2 +- .../api/v1/workflows/[id]/rollback/route.ts | 2 +- apps/sim/app/api/v2/lib/response.ts | 34 +++ .../v2/tables/[tableId]/columns/route.test.ts | 105 +++++++ .../api/v2/tables/[tableId]/columns/route.ts | 103 ++----- .../app/api/v2/tables/[tableId]/route.test.ts | 109 +++++++ apps/sim/app/api/v2/tables/[tableId]/route.ts | 24 +- .../[tableId]/rows/[rowId]/route.test.ts | 99 +++++++ .../v2/tables/[tableId]/rows/[rowId]/route.ts | 43 +-- .../v2/tables/[tableId]/rows/upsert/route.ts | 17 +- apps/sim/app/api/v2/tables/route.ts | 15 +- apps/sim/app/api/v2/tables/utils.ts | 11 + .../app/api/workflows/[id]/deploy/route.ts | 2 +- .../[id]/deployments/[version]/route.ts | 2 +- .../lib/copilot/tools/handlers/vfs-mutate.ts | 24 +- .../tools/server/table/user-table.test.ts | 59 +--- .../copilot/tools/server/table/user-table.ts | 194 +++---------- .../orchestration/types.test.ts | 2 +- apps/sim/lib/core/orchestration/types.ts | 74 +++++ apps/sim/lib/folders/config.ts | 2 +- apps/sim/lib/folders/lifecycle.ts | 2 +- apps/sim/lib/folders/status.ts | 2 +- apps/sim/lib/table/billing.ts | 10 +- apps/sim/lib/table/columns/service.ts | 123 +++++--- apps/sim/lib/table/import-data.ts | 14 +- apps/sim/lib/table/import.ts | 19 +- .../lib/table/orchestration/columns.test.ts | 224 ++++++++++++++ apps/sim/lib/table/orchestration/columns.ts | 266 +++++++++++++++++ apps/sim/lib/table/orchestration/index.ts | 73 +---- apps/sim/lib/table/orchestration/restore.ts | 63 ++++ .../lib/table/orchestration/tables.test.ts | 142 +++++++++ apps/sim/lib/table/orchestration/tables.ts | 274 ++++++++++++++++++ apps/sim/lib/table/rows/service.ts | 113 ++++++-- apps/sim/lib/table/select-options.test.ts | 59 ++++ apps/sim/lib/table/select-options.ts | 32 ++ apps/sim/lib/table/service.ts | 159 ++++------ apps/sim/lib/table/workflow-columns.ts | 6 +- .../sim/lib/workflows/orchestration/deploy.ts | 2 +- .../orchestration/folder-lifecycle.ts | 2 +- apps/sim/lib/workflows/orchestration/types.ts | 12 - .../orchestration/workflow-lifecycle.ts | 2 +- 62 files changed, 2170 insertions(+), 1330 deletions(-) create mode 100644 apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts rename apps/sim/lib/{workflows => core}/orchestration/types.test.ts (82%) create mode 100644 apps/sim/lib/core/orchestration/types.ts create mode 100644 apps/sim/lib/table/orchestration/columns.test.ts create mode 100644 apps/sim/lib/table/orchestration/columns.ts create mode 100644 apps/sim/lib/table/orchestration/restore.ts create mode 100644 apps/sim/lib/table/orchestration/tables.test.ts create mode 100644 apps/sim/lib/table/orchestration/tables.ts create mode 100644 apps/sim/lib/table/select-options.test.ts delete mode 100644 apps/sim/lib/workflows/orchestration/types.ts 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 5cad8c5e610..f55c4a0bc52 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.ts @@ -8,20 +8,11 @@ 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 { performUpdateTableColumn } from '@/lib/table/orchestration' import { accessError, checkAccess, @@ -120,215 +111,32 @@ 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 } + { error: outcome.error ?? 'Failed to update column' }, + { status: statusForOrchestrationError(outcome.errorCode) } ) } - 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 - ) - } - - // 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 }) - } - 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 824ce73ddb4..7a047120f75 100644 --- a/apps/sim/app/api/table/[tableId]/columns/run/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/run/route.ts @@ -8,7 +8,12 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { TableQueryValidationError } from '@/lib/table/errors' 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') @@ -66,9 +71,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]/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 7a9802442cc..0b6cf78a319 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -14,6 +14,7 @@ import { import { ianaTimezoneSchema } from '@/lib/api/contracts/user' import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { asOrchestrationError, 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' @@ -349,21 +350,13 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro 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) + const classified = asOrchestrationError(err) return NextResponse.json( { - error: isClientError ? message : 'Failed to import CSV', + error: classified ? classified.message : 'Failed to import CSV', data: { insertedCount: 0 }, }, - { status: isClientError ? 400 : 500 } + { status: classified ? statusForOrchestrationError(classified.code) : 500 } ) } } @@ -400,17 +393,12 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro }, }) } 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 }) + const classified = asOrchestrationError(err) + if (classified) { + return NextResponse.json( + { error: classified.message }, + { status: statusForOrchestrationError(classified.code) } + ) } throw err } @@ -419,17 +407,12 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro 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') - + const classified = asOrchestrationError(error) return NextResponse.json( - { error: isClientError ? message : 'Failed to import CSV' }, - { status: isClientError ? 400 : 500 } + { error: classified ? classified.message : 'Failed to import CSV' }, + { status: classified ? statusForOrchestrationError(classified.code) : 500 } ) } finally { fileStream?.destroy() 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 03eaa4c7243..7d75286cf83 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -5,20 +5,18 @@ 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 { + 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 { @@ -180,11 +178,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) + 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) { @@ -196,21 +218,22 @@ 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 } } @@ -268,14 +291,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 82ec048ca84..6f4636d9aa1 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,15 +10,17 @@ 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 { 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' @@ -173,10 +174,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 @@ -212,7 +209,13 @@ 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) } + ) + } return NextResponse.json({ success: true, @@ -225,11 +228,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/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..f84f457e820 100644 --- a/apps/sim/app/api/table/import-csv/route.ts +++ b/apps/sim/app/api/table/import-csv/route.ts @@ -1,6 +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, csvImportFormSchema } from '@/lib/api/contracts/tables' @@ -34,7 +33,7 @@ import { csvProxyBodyCapResponse, multipartErrorResponse, normalizeColumn, - rowWriteErrorResponse, + orchestrationErrorResponse, } from '@/app/api/table/utils' const logger = createLogger('TableImportCSV') @@ -250,22 +249,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { 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 + // Every caller-fixable failure on this path — the plan row-limit check, the + // schema and CSV-shape validation, a name collision — arrives classified. + const classified = orchestrationErrorResponse(error) + if (classified) return classified - 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/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/v1/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts index d1a507fa195..78dca2d367f 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts @@ -7,24 +7,16 @@ 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 { performUpdateTableColumn } from '@/lib/table/orchestration' import { accessError, checkAccess, normalizeColumn, + orchestrationErrorResponse, tableLockErrorResponse, } from '@/app/api/table/utils' import { @@ -101,22 +93,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 }) @@ -154,227 +132,31 @@ 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 } + { error: outcome.error ?? 'Failed to update column' }, + { status: statusForOrchestrationError(outcome.errorCode) } ) } - 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 - ) - } - - // 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 }) - } - - 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 }) } @@ -441,14 +223,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 5fee4f3d03b..dbe768ee830 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,13 +9,20 @@ 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 { 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, @@ -188,21 +194,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 }) @@ -238,9 +231,13 @@ 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) } + ) + } return NextResponse.json({ success: true, @@ -252,9 +249,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 bf4a00df91b..a32f17a9c8e 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' @@ -9,7 +8,12 @@ import type { RowData, TableSchema } from '@/lib/table' import { upsertRow } from '@/lib/table' import { namedRowMapper } from '@/lib/table/cell-format' import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys' -import { accessError, checkAccess, tableLockErrorResponse } from '@/app/api/table/utils' +import { + accessError, + checkAccess, + orchestrationErrorResponse, + tableLockErrorResponse, +} from '@/app/api/table/utils' import { checkRateLimit, checkWorkspaceScope, @@ -101,19 +105,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]/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/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index bd326d1218d..45ed1e6fcb3 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server' import type { ZodError } from 'zod' 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' /** @@ -155,3 +156,36 @@ export function decodeCursor>(cursor: string): T | n return null } } + +const V2_CODE_BY_ORCHESTRATION_ERROR: Record = { + validation: 'BAD_REQUEST', + forbidden: 'FORBIDDEN', + not_found: 'NOT_FOUND', + conflict: 'CONFLICT', + locked: 'LOCKED', + 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 +): NextResponse { + const v2Code = code ? V2_CODE_BY_ORCHESTRATION_ERROR[code] : 'INTERNAL_ERROR' + return v2Error(v2Code, v2Code === 'INTERNAL_ERROR' ? 'Internal server error' : message) +} + +/** + * 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/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 index c7c1e538600..ce480142cab 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts @@ -10,19 +10,16 @@ import { 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, - renameColumn, - updateColumnConstraints, - updateColumnType, -} from '@/lib/table' +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, + v2ErrorForOrchestration, v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, @@ -88,14 +85,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum } catch (error) { if (isZodError(error)) return v2ValidationError(error) - if (error instanceof Error) { - if (error.message.includes('already exists') || error.message.includes('maximum column')) { - return v2Error('BAD_REQUEST', error.message) - } - if (error.message === 'Table not found') { - return v2Error('NOT_FOUND', error.message) - } - } + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified logger.error(`[${requestId}] Error adding column to table`, { error: getErrorMessage(error, 'Unknown error'), @@ -136,73 +127,21 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu return v2Error('NOT_FOUND', 'Table not found') } - const { updates } = validated - let updatedTable = null - - if (updates.name) { - updatedTable = await renameColumn( - { tableId, oldName: validated.columnName, newName: updates.name }, - requestId - ) - } - - if (updates.type) { - updatedTable = await updateColumnType( - { tableId, columnName: updates.name ?? validated.columnName, newType: updates.type }, - requestId - ) - } - - if (updates.required !== undefined || updates.unique !== undefined) { - updatedTable = await updateColumnConstraints( - { - tableId, - columnName: updates.name ?? validated.columnName, - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - }, - requestId - ) - } - - if (!updatedTable) { - return v2Error('BAD_REQUEST', 'No updates specified') - } - - 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 }, + const outcome = await performUpdateTableColumn({ + table, + columnName: validated.columnName, + userId, + updates: validated.updates, + requestId, request, }) + if (!outcome.success || !outcome.table) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? 'Failed to update column') + } - return v2Data({ columns: updatedTable.schema.columns.map(normalizeColumn) }, { rateLimit }) + return v2Data({ columns: outcome.table.schema.columns.map(normalizeColumn) }, { rateLimit }) } catch (error) { if (isZodError(error)) return v2ValidationError(error) - - if (error instanceof Error) { - const msg = error.message - if (msg.includes('not found') || msg.includes('Table not found')) { - return v2Error('NOT_FOUND', msg) - } - 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') - ) { - return v2Error('BAD_REQUEST', msg) - } - } - logger.error(`[${requestId}] Error updating column in table`, { error: getErrorMessage(error, 'Unknown error'), }) @@ -264,14 +203,8 @@ export const DELETE = withRouteHandler( } catch (error) { if (isZodError(error)) return v2ValidationError(error) - if (error instanceof Error) { - if (error.message.includes('not found') || error.message === 'Table not found') { - return v2Error('NOT_FOUND', error.message) - } - if (error.message.includes('Cannot delete') || error.message.includes('last column')) { - return v2Error('BAD_REQUEST', error.message) - } - } + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified logger.error(`[${requestId}] Error deleting column from table`, { error: getErrorMessage(error, 'Unknown 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..43210d8a8e8 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts @@ -0,0 +1,109 @@ +/** + * @vitest-environment node + * + * Public v2 table delete: the actor is handed to the service so the audit is + * emitted there — and only for a delete that actually archived a row. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockPerformDeleteTable, + mockRecordAudit, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockPerformDeleteTable: vi.fn(), + mockRecordAudit: 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: vi.fn(), + updateRow: vi.fn(), + rowDataNameToId: vi.fn(), + buildIdByName: vi.fn(), +})) + +vi.mock('@/lib/table/orchestration', () => ({ performDeleteTable: mockPerformDeleteTable })) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { DELETE } from '@/app/api/v2/tables/[tableId]/route' + +const TABLE = { id: 'table-1', name: 'Tasks', workspaceId: 'ws-1', schema: { columns: [] } } + +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' }) }) +} + +describe('DELETE /api/v2/tables/[tableId]', () => { + 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 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' }) + ) + // 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') + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts index 03e97d590fa..55e2d792f8f 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' @@ -6,18 +5,19 @@ import { v2DeleteTableContract, v2GetTableContract } from '@/lib/api/contracts/v import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { deleteTable } from '@/lib/table' +import { performDeleteTable } 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 { v2Data, v2Error, + v2ErrorForOrchestration, v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -import { toApiTable, v2TableAccessError } from '@/app/api/v2/tables/utils' +import { toApiTable, v2TableAccessError, v2TableLockError } from '@/app/api/v2/tables/utils' const logger = createLogger('V2TableDetailAPI') @@ -100,21 +100,15 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab return v2Error('NOT_FOUND', 'Table not found') } - 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 v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? 'Failed to delete table') + } return v2Data({ id: tableId }, { rateLimit }) } catch (error) { + const lockError = v2TableLockError(error) + if (lockError) return lockError logger.error(`[${requestId}] Error deleting table`, { error: getErrorMessage(error, 'Unknown 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..2139216449a --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts @@ -0,0 +1,99 @@ +/** + * @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) + }) +}) 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 index f11cf9b2c74..b0bb10b78d3 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getErrorMessage, toError } from '@sim/utils/errors' +import { getErrorMessage } from '@sim/utils/errors' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { @@ -15,17 +15,20 @@ 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, + v2ErrorForOrchestration, v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -import { toApiRow, v2TableAccessError } from '@/app/api/v2/tables/utils' +import { toApiRow, v2TableAccessError, v2TableLockError } from '@/app/api/v2/tables/utils' const logger = createLogger('V2TableRowAPI') @@ -163,18 +166,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR } catch (error) { if (isZodError(error)) return v2ValidationError(error) - const errorMessage = toError(error).message - if (errorMessage === 'Row not found') return v2Error('NOT_FOUND', errorMessage) - - 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 v2Error('BAD_REQUEST', errorMessage) - } + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified logger.error(`[${requestId}] Error updating row`, { error: getErrorMessage(error, 'Unknown error'), @@ -214,22 +207,18 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row return v2Error('NOT_FOUND', 'Table not found') } - const [deletedRow] = await db - .delete(userTableRows) - .where( - and( - eq(userTableRows.id, rowId), - eq(userTableRows.tableId, tableId), - eq(userTableRows.workspaceId, workspaceId) - ) - ) - .returning({ id: userTableRows.id }) - - if (!deletedRow) return v2Error('NOT_FOUND', 'Row not found') + const outcome = await performDeleteTableRow({ table: result.table, rowId, requestId }) + if (!outcome.success) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? 'Failed to delete row') + } // v2 mirrors the bulk delete shape: always returns `deletedRowIds`. - return v2Data({ deletedCount: 1, deletedRowIds: [deletedRow.id] }, { rateLimit }) + 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'), }) 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 index 08f4b0873af..a8b4c21593b 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage, toError } from '@sim/utils/errors' +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' @@ -12,6 +12,7 @@ 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, @@ -82,18 +83,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser } catch (error) { if (isZodError(error)) return v2ValidationError(error) - 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 v2Error('BAD_REQUEST', errorMessage) - } + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified logger.error(`[${requestId}] Error upserting row`, { error: getErrorMessage(error, 'Unknown error'), diff --git a/apps/sim/app/api/v2/tables/route.ts b/apps/sim/app/api/v2/tables/route.ts index 9082e9280b9..85df923214c 100644 --- a/apps/sim/app/api/v2/tables/route.ts +++ b/apps/sim/app/api/v2/tables/route.ts @@ -11,6 +11,7 @@ import { normalizeColumn } from '@/app/api/table/utils' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { + v2CaughtOrchestrationError, v2CursorList, v2Data, v2Error, @@ -128,18 +129,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { if (isZodError(error)) return v2ValidationError(error) - if (error instanceof Error) { - if (error.message.includes('maximum table limit')) { - return v2Error('FORBIDDEN', error.message) - } - if ( - error.message.includes('Invalid table name') || - error.message.includes('Invalid schema') || - error.message.includes('already exists') - ) { - return v2Error('BAD_REQUEST', error.message) - } - } + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified logger.error(`[${requestId}] Error creating table`, { error: getErrorMessage(error, 'Unknown error'), diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts index 00a9510feb8..8d662be3d4a 100644 --- a/apps/sim/app/api/v2/tables/utils.ts +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -1,5 +1,6 @@ import type { NextResponse } from 'next/server' import type { RowData, TableDefinition, TablePredicate, TableSchema } from '@/lib/table' +import { TableLockedError } from '@/lib/table/mutation-locks' import { predicateToFilter } from '@/lib/table/query-builder/converters' import { validatePredicateShape, @@ -95,6 +96,16 @@ export function v2TableAccessError(result: { ok: false; status: 404 | 403 }): Ne : 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. + */ +export function v2TableLockError(error: unknown): NextResponse | null { + if (error instanceof TableLockedError) return v2Error('LOCKED', error.message) + return null +} + /** * 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 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/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts index e5b7ffefda4..602d71664cb 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -22,7 +22,8 @@ import { getKnowledgeBases, updateKnowledgeBase, } from '@/lib/knowledge/service' -import { deleteTable, listTables, renameTable } from '@/lib/table/service' +import { performDeleteTable, performRenameTable } from '@/lib/table/orchestration' +import { listTables } from '@/lib/table/service' import { ensureWorkspaceFileFolderPath, findWorkspaceFileFolderIdByPath, @@ -759,11 +760,19 @@ async function renameFlatResource( return { success: false, error: `Table not found at ${sources[0]}` } } assertMutationNotAborted(context) - const renamed = await renameTable(match.id, newName, generateRequestId()) + const renameOutcome = await performRenameTable({ + table: match, + newName, + userId: context.userId, + requestId: generateRequestId(), + }) + if (!renameOutcome.success) { + return { success: false, error: renameOutcome.error ?? 'Failed to rename table' } + } return buildResult(verb, [ { from: sources[0], - to: `tables/${normalizeVfsSegment(renamed.name)}`, + to: `tables/${normalizeVfsSegment(newName)}`, kind, id: match.id, }, @@ -1018,7 +1027,14 @@ async function removeTablePath( ) if (!match) return { from: path, kind: 'table', error: `Table not found at ${path}` } - await deleteTable(match.id, generateRequestId(), context.userId) + const outcome = await performDeleteTable({ + table: match, + userId: context.userId, + requestId: generateRequestId(), + }) + if (!outcome.success) { + return { from: path, kind: 'table', error: outcome.error ?? 'Failed to archive table' } + } logger.info('Archived table via rm', { tableId: match.id, workspaceId }) return { from: path, kind: 'table', id: match.id } } diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts index adebd05bd7a..512401f65ea 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts @@ -137,10 +137,7 @@ vi.mock('@/lib/table/billing', () => ({ getWorkspaceTableLimits: mockGetWorkspaceTableLimits, })) -import { - normalizeSelectOptionsInput, - userTableServerTool, -} from '@/lib/copilot/tools/server/table/user-table' +import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' import { encodeCursor } from '@/lib/table/rows/cursor' function buildTable(overrides: Partial = {}): TableDefinition { @@ -172,60 +169,6 @@ async function flushDetached(): Promise { await Promise.resolve() } -describe('normalizeSelectOptionsInput', () => { - it('generates a stable id for a bare-name string option', () => { - const [opt] = normalizeSelectOptionsInput(['Open']) ?? [] - expect(opt.name).toBe('Open') - expect(typeof opt.id).toBe('string') - expect(opt.id.length).toBeGreaterThan(0) - }) - - it('generates an id for an object option without one', () => { - const [opt] = normalizeSelectOptionsInput([{ name: 'Closed' }]) ?? [] - expect(opt.name).toBe('Closed') - expect(opt.id.length).toBeGreaterThan(0) - }) - - it('preserves an explicitly supplied id', () => { - const result = normalizeSelectOptionsInput([{ id: 'opt_keep', name: 'Open' }]) - expect(result).toEqual([{ id: 'opt_keep', name: 'Open' }]) - }) - - it('reuses the id of an existing option with the same name', () => { - // The agent re-sends options as bare names on every edit. Minting fresh ids - // would orphan every cell holding them — silently clearing the column. - const existing = [ - { id: 'opt_low', name: 'Low' }, - { id: 'opt_high', name: 'High' }, - ] - const result = normalizeSelectOptionsInput(['Low', 'Medium', 'High'], existing) ?? [] - - expect(result[0]).toEqual({ id: 'opt_low', name: 'Low' }) - expect(result[2]).toEqual({ id: 'opt_high', name: 'High' }) - // Only the genuinely new option gets a fresh id. - expect(result[1].name).toBe('Medium') - expect(result[1].id).not.toBe('opt_low') - expect(result[1].id).not.toBe('opt_high') - }) - - it('matches an existing option name case-insensitively', () => { - const result = normalizeSelectOptionsInput(['open'], [{ id: 'opt_open', name: 'Open' }]) ?? [] - expect(result[0].id).toBe('opt_open') - expect(result[0].name).toBe('open') - }) - - it('mints a fresh id when there is no existing column to match against', () => { - const result = normalizeSelectOptionsInput(['Open']) ?? [] - expect(result[0].id.length).toBeGreaterThan(0) - expect(result[0].name).toBe('Open') - }) - - it('returns undefined for a non-array (validation rejects it downstream)', () => { - expect(normalizeSelectOptionsInput(undefined)).toBeUndefined() - expect(normalizeSelectOptionsInput('Open')).toBeUndefined() - }) -}) - describe('userTableServerTool.import_file', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index 036ceb2cd40..93b0062c7b6 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -1,7 +1,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import { generateId, generateShortId } from '@sim/utils/id' +import { generateId } from '@sim/utils/id' import { UserTable } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, @@ -10,7 +10,6 @@ import { } from '@/lib/copilot/tools/server/base-tool' import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { runDetached } from '@/lib/core/utils/background' -import { captureServerEvent } from '@/lib/posthog/server' import { buildAutoMapping, COLUMN_TYPES, @@ -27,29 +26,24 @@ import { validateMapping, } from '@/lib/table' import { namedRowMapper } from '@/lib/table/cell-format' -import { - buildIdByName, - columnMatchesRef, - rowDataNameToId, - sortSpecNamesToIds, -} from '@/lib/table/column-keys' +import { buildIdByName, rowDataNameToId, sortSpecNamesToIds } from '@/lib/table/column-keys' import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-naming' -import { columnTypeById } from '@/lib/table/column-types' import { addTableColumn, deleteColumn, deleteColumns, renameColumn, - updateColumnConstraints, - updateColumnCurrency, - updateColumnOptions, - updateColumnType, } from '@/lib/table/columns/service' import { isSupportedCurrencyCode } from '@/lib/table/currency' import { markTableDeleteFailed, runTableDelete } from '@/lib/table/delete-runner' import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' import { assertRowDelete, assertRowUpdate, patchColumnIds } from '@/lib/table/mutation-locks' +import { + performDeleteTable, + performRenameTable, + performUpdateTableColumn, +} from '@/lib/table/orchestration' import { predicateToFilter } from '@/lib/table/query-builder/converters' import { validatePredicate, validateSortSpec } from '@/lib/table/query-builder/validate' import { assertCursorSortBinding, decodeCursor } from '@/lib/table/rows/cursor' @@ -66,13 +60,13 @@ import { updateRow, updateRowsByFilter, } from '@/lib/table/rows/service' +import { normalizeSelectOptionsInput } from '@/lib/table/select-options' import { predicateToStorage } from '@/lib/table/select-values' -import { createTable, deleteTable, getTableById, renameTable } from '@/lib/table/service' +import { createTable, deleteTable, getTableById } from '@/lib/table/service' import type { ColumnDefinition, Filter, RowData, - SelectOption, SortSpec, TableDefinition, TableDeleteJobPayload, @@ -334,30 +328,6 @@ function limitError(limit: unknown): string | null { * cell data survives the update. Non-array input returns `undefined`, letting * downstream validation reject a malformed / missing option set. */ -export function normalizeSelectOptionsInput( - raw: unknown, - existing: SelectOption[] = [] -): SelectOption[] | undefined { - if (!Array.isArray(raw)) return undefined - // Cells reference the option id, so an edit that re-sends the same option by - // name must reuse its id — minting a fresh one would orphan every cell - // holding it, silently clearing the column. - const idByName = new Map() - for (const option of existing) { - const key = option.name.toLowerCase() - if (!idByName.has(key)) idByName.set(key, option.id) - } - const resolveId = (name: string): string => idByName.get(name.toLowerCase()) ?? generateShortId() - - return raw.map((entry) => { - if (typeof entry === 'string') return { id: resolveId(entry), name: entry } - const e = (entry ?? {}) as { id?: unknown; name?: unknown } - const name = typeof e.name === 'string' ? e.name : String(e.name ?? '') - const id = typeof e.id === 'string' && e.id.length > 0 ? e.id : resolveId(name) - return { id, name } - }) -} - /** Rewrites every `select` column's options in an agent-authored create schema. */ function normalizeSchemaSelectColumns(schema: TableSchema): TableSchema { if (!schema || !Array.isArray(schema.columns)) return schema @@ -523,13 +493,14 @@ export const userTableServerTool: BaseServerTool const requestId = generateId().slice(0, 8) assertNotAborted() - await deleteTable(tableId, requestId, context.userId) - captureServerEvent( - context.userId, - 'table_deleted', - { table_id: tableId, workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) + const deleteOutcome = await performDeleteTable({ + table, + userId: context.userId, + requestId, + }) + if (!deleteOutcome.success) { + return { success: false, message: deleteOutcome.error ?? 'Failed to delete table' } + } deleted.push(tableId) } @@ -1683,117 +1654,38 @@ export const userTableServerTool: BaseServerTool message: `Invalid currency code "${currencyCode}". Use an ISO 4217 code, e.g. USD`, } } - const tableForUpdate = await getTableById(args.tableId) - if (!tableForUpdate || tableForUpdate.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - const requestId = generateId().slice(0, 8) - // The agent authors options by name; mint ids here, reusing the id of - // any option whose name already exists so its cells survive the edit. - const currentColumn = tableForUpdate.schema.columns.find((c) => - columnMatchesRef(c, colName) - ) - const existingOptions = currentColumn?.options ?? [] - const options = normalizeSelectOptionsInput(rawOptions, existingOptions) - // An agent restating the current type alongside new options must not - // go through `updateColumnType` — it early-returns on an unchanged - // type and would drop them. Mirrors the HTTP columns route. - const typeChanging = newType !== undefined && newType !== currentColumn?.type - let result: TableDefinition | undefined if (newType !== undefined && !(COLUMN_TYPES as readonly string[]).includes(newType)) { return { success: false, message: `Invalid column type "${newType}". Must be one of: ${COLUMN_TYPES.join(', ')}`, } } - // Each write below is its own locked transaction, so pairing any of - // them with a constraint write that is going to fail commits and then - // errors. Gate on the type the column ENDS UP with — an options-only - // update on an existing select column carries the same hazard as a - // conversion. Same guard the HTTP column routes apply. - const resultingType = newType ?? currentColumn?.type - if (uniqFlag === true && !columnTypeById(resultingType).supportsUnique) { - return { - success: false, - message: `Cannot set column "${colName}" as unique: ${resultingType} columns cannot be unique.`, - } - } - if (typeChanging) { - assertNotAborted() - result = await updateColumnType( - { - tableId: args.tableId, - columnName: colName, - newType: newType as (typeof COLUMN_TYPES)[number], - options, - multiple, - ...(currencyCode !== undefined ? { currencyCode } : {}), - ...(uniqFlag !== undefined ? { unique: uniqFlag } : {}), - }, - requestId - ) - } else if (currencyCode !== undefined) { - // Re-denominating an existing currency column: schema-only, no cell - // rewrite. Mirrors the HTTP columns routes. - if (currentColumn?.type !== 'currency') { - return { - success: false, - message: `Column "${colName}" is not a currency column. Pass newType: "currency" with currencyCode to convert it.`, - } - } - assertNotAborted() - result = await updateColumnCurrency( - { - tableId: args.tableId, - columnName: colName, - currencyCode, - ...(uniqFlag !== undefined ? { unique: uniqFlag } : {}), - }, - requestId - ) - } else if (options !== undefined || multiple !== undefined) { - // Editing an existing select column's option set / mode without a - // type change. `multiple` alone is a valid update — the catalog - // documents it as independent — so fall back to the column's current - // options rather than demanding the caller resend the whole list. - const nextOptions = options ?? existingOptions - if (nextOptions.length === 0) { - return { - success: false, - message: `Column "${colName}" is not a select column. Pass newType: "select" with options to convert it.`, - } - } - assertNotAborted() - result = await updateColumnOptions( - { - tableId: args.tableId, - columnName: colName, - options: nextOptions, - multiple, - ...(uniqFlag !== undefined ? { unique: uniqFlag } : {}), - }, - requestId - ) + const tableForUpdate = await getTableById(args.tableId) + if (!tableForUpdate || tableForUpdate.workspaceId !== workspaceId) { + return { success: false, message: `Table not found: ${args.tableId}` } } - // Skipped when a typed write ran: that write already applied and - // validated the constraint, in one transaction with the change it - // accompanies. Mirrors the HTTP columns routes. - if (uniqFlag !== undefined && result === undefined) { - assertNotAborted() - result = await updateColumnConstraints( - { tableId: args.tableId, columnName: colName, unique: uniqFlag }, - requestId - ) + assertNotAborted() + const outcome = await performUpdateTableColumn({ + table: tableForUpdate, + columnName: colName, + userId: context.userId, + updates: { + ...(newType !== undefined ? { type: newType as (typeof COLUMN_TYPES)[number] } : {}), + ...(uniqFlag !== undefined ? { unique: uniqFlag } : {}), + ...(rawOptions !== undefined ? { options: rawOptions } : {}), + ...(multiple !== undefined ? { multiple } : {}), + ...(currencyCode !== undefined ? { currencyCode } : {}), + }, + }) + if (!outcome.success || !outcome.table) { + return { success: false, message: outcome.error ?? 'Failed to update column' } } return { success: true, message: `Updated column "${colName}"`, - // A payload that only restates the current type is a no-op; still - // report the live schema rather than an undefined one. - data: { schema: (result ?? tableForUpdate).schema }, + data: { schema: outcome.table.schema }, } } - case 'rename': { if (!args.tableId) { return { success: false, message: 'Table ID is required' } @@ -1813,12 +1705,20 @@ export const userTableServerTool: BaseServerTool const requestId = generateId().slice(0, 8) assertNotAborted() - const renamed = await renameTable(args.tableId, newName, requestId, context.userId) + const renameOutcome = await performRenameTable({ + table, + newName, + userId: context.userId, + requestId, + }) + if (!renameOutcome.success) { + return { success: false, message: renameOutcome.error ?? 'Failed to rename table' } + } return { success: true, - message: `Renamed table to "${renamed.name}"`, - data: { table: { id: renamed.id, name: renamed.name } }, + message: `Renamed table to "${newName}"`, + data: { table: { id: args.tableId, name: newName } }, } } diff --git a/apps/sim/lib/workflows/orchestration/types.test.ts b/apps/sim/lib/core/orchestration/types.test.ts similarity index 82% rename from apps/sim/lib/workflows/orchestration/types.test.ts rename to apps/sim/lib/core/orchestration/types.test.ts index 26be2502be4..af8104841a1 100644 --- a/apps/sim/lib/workflows/orchestration/types.test.ts +++ b/apps/sim/lib/core/orchestration/types.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' describe('statusForOrchestrationError', () => { it.each([ diff --git a/apps/sim/lib/core/orchestration/types.ts b/apps/sim/lib/core/orchestration/types.ts new file mode 100644 index 00000000000..59ccf54aad6 --- /dev/null +++ b/apps/sim/lib/core/orchestration/types.ts @@ -0,0 +1,74 @@ +export type OrchestrationErrorCode = + | 'validation' + | 'not_found' + | 'forbidden' + | 'conflict' + | 'locked' + | 'internal' + +/** + * Transport-neutral failure classes returned by every `lib/[resource]/orchestration` + * module, so the UI routes, the public API, and the copilot tools map the same + * failure to the same status. + */ +export function statusForOrchestrationError(code: OrchestrationErrorCode | undefined): number { + if (code === 'validation') return 400 + if (code === 'forbidden') return 403 + if (code === 'not_found') return 404 + if (code === 'conflict') return 409 + if (code === 'locked') return 423 + return 500 +} + +/** + * A domain failure that already knows its own class. + * + * Services throw this instead of a bare `Error` whenever the failure is + * caller-fixable, so the layers above classify by `instanceof` and read `code` + * rather than searching the message for a phrase. Message text is then free to + * be reworded, translated, or made more specific without silently changing the + * status every caller returns — the failure mode this replaced, where adding + * "already exists" to a message demoted a 409 to a 400. + * + * The code is transport-neutral on purpose: `statusForOrchestrationError` maps + * it for the UI and v1 routes, `v2ErrorForOrchestration` maps it to the v2 + * error vocabulary, and the copilot tools surface `message` with no status at + * all. An anything-else error stays unclassified and becomes a generic 500, + * which is what an unexpected fault should be. + */ +export class OrchestrationError extends Error { + constructor( + readonly code: OrchestrationErrorCode, + message: string + ) { + super(message) + this.name = 'OrchestrationError' + } +} + +/** + * The {@link OrchestrationError} in `error`'s cause chain, or `null` when the + * failure is not a classified one. + * + * Walks `cause` rather than testing `error` alone because drizzle wraps a throw + * raised inside a transaction callback in a `DrizzleQueryError` whose own + * message is the failed SQL — the same reason the message-matching this + * replaced had to dig for a root cause before it could classify anything. + */ +export function asOrchestrationError(error: unknown): OrchestrationError | null { + let current: unknown = error + while (current instanceof Error) { + if (current instanceof OrchestrationError) return current + current = current.cause + } + return null +} + +/** + * The slice of an HTTP request the audit log reads for client IP and user-agent + * capture. Optional on every orchestration function so the non-HTTP callers — + * copilot tools, background jobs — can omit what they do not have. + */ +export interface OrchestrationRequestContext { + headers: { get(name: string): string | null } +} diff --git a/apps/sim/lib/folders/config.ts b/apps/sim/lib/folders/config.ts index 4c6b3d6333e..5c69755b75e 100644 --- a/apps/sim/lib/folders/config.ts +++ b/apps/sim/lib/folders/config.ts @@ -304,7 +304,7 @@ async function archiveTableChildren(context: CascadeChildrenContext): Promise { assertSchemaMutable(table) if (!NAME_PATTERN.test(column.name)) { - throw new Error( + throw new OrchestrationError( + 'validation', `Invalid column name "${column.name}". Must start with a letter or underscore and contain only alphanumeric characters and underscores.` ) } if (column.name.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) { - throw new Error( + throw new OrchestrationError( + 'validation', `Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)` ) } if (!COLUMN_TYPES.includes(column.type as (typeof COLUMN_TYPES)[number])) { - throw new Error( + throw new OrchestrationError( + 'validation', `Invalid column type "${column.type}". Must be one of: ${COLUMN_TYPES.join(', ')}` ) } const schema = table.schema if (schema.columns.some((c) => c.name.toLowerCase() === column.name.toLowerCase())) { - throw new Error(`Column "${column.name}" already exists`) + throw new OrchestrationError('validation', `Column "${column.name}" already exists`) } if (schema.columns.length >= TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { - throw new Error( + throw new OrchestrationError( + 'validation', `Table has reached maximum column limit (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE})` ) } @@ -124,7 +135,10 @@ export async function addTableColumn( const columnValidation = validateColumnDefinition(newColumn) if (!columnValidation.valid) { - throw new Error(`Invalid column: ${columnValidation.errors.join('; ')}`) + throw new OrchestrationError( + 'validation', + `Invalid column: ${columnValidation.errors.join('; ')}` + ) } const newColumnId = getColumnId(newColumn) @@ -194,13 +208,15 @@ export async function renameColumn( return withLockedTable(data.tableId, async (table, trx) => { assertSchemaMutable(table) if (!NAME_PATTERN.test(data.newName)) { - throw new Error( + throw new OrchestrationError( + 'validation', `Invalid column name "${data.newName}". Column names must start with a letter or underscore, followed by alphanumeric characters or underscores.` ) } if (data.newName.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) { - throw new Error( + throw new OrchestrationError( + 'validation', `Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)` ) } @@ -208,7 +224,7 @@ export async function renameColumn( const schema = table.schema const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.oldName)) if (columnIndex === -1) { - throw new Error(`Column "${data.oldName}" not found`) + throw new OrchestrationError('not_found', `Column "${data.oldName}" not found`) } if ( @@ -216,7 +232,7 @@ export async function renameColumn( (c, i) => i !== columnIndex && c.name.toLowerCase() === data.newName.toLowerCase() ) ) { - throw new Error(`Column "${data.newName}" already exists`) + throw new OrchestrationError('validation', `Column "${data.newName}" already exists`) } const targetColumn = schema.columns[columnIndex] @@ -331,11 +347,11 @@ export async function deleteColumn( const schema = table.schema const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) if (columnIndex === -1) { - throw new Error(`Column "${data.columnName}" not found`) + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) } if (schema.columns.length <= 1) { - throw new Error('Cannot delete the last column in a table') + throw new OrchestrationError('validation', 'Cannot delete the last column in a table') } const targetColumn = schema.columns[columnIndex] @@ -423,12 +439,12 @@ export async function deleteColumns( } if (notFound.length > 0) { - throw new Error(`Columns not found: ${notFound.join(', ')}`) + throw new OrchestrationError('not_found', `Columns not found: ${notFound.join(', ')}`) } const remaining = schema.columns.filter((c) => !namesToDelete.has(c.name)) if (remaining.length === 0) { - throw new Error('Cannot delete all columns from a table') + throw new OrchestrationError('validation', 'Cannot delete all columns from a table') } // For each group, drop outputs whose column (by id) is being deleted. Groups @@ -506,26 +522,32 @@ async function applyConstraints( if (data.required === undefined && data.unique === undefined) return column if (column.workflowGroupId) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot change constraints on workflow-output column "${column.name}". Constraints aren't applicable to columns whose values come from workflow execution.` ) } if (data.required === true && !column.required) { const emptyCount = await countEmptyCells(trx, tableId, columnKey) if (emptyCount > 0) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot set column "${column.name}" as required: ${emptyCount} row(s) have null, missing, or empty values` ) } } if (data.unique === true && !column.unique) { if (!columnTypeOf(column).supportsUnique) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot set column "${column.name}" as unique: ${column.type} columns compare stored values that would allow only one row per value.` ) } if (await hasDuplicateValues(trx, tableId, columnKey)) { - throw new Error(`Cannot set column "${column.name}" as unique: duplicate values exist`) + throw new OrchestrationError( + 'validation', + `Cannot set column "${column.name}" as unique: duplicate values exist` + ) } } return { @@ -592,17 +614,19 @@ export function applyPendingRename( if (newName === undefined || newName === column.name) return column if (!NAME_PATTERN.test(newName)) { - throw new Error( + throw new OrchestrationError( + 'validation', `Invalid column name "${newName}". Column names must start with a letter or underscore, followed by alphanumeric characters or underscores.` ) } if (newName.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) { - throw new Error( + throw new OrchestrationError( + 'validation', `Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)` ) } if (columns.some((c, i) => i !== columnIndex && c.name.toLowerCase() === newName.toLowerCase())) { - throw new Error(`Column "${newName}" already exists`) + throw new OrchestrationError('validation', `Column "${newName}" already exists`) } return { ...column, name: newName } } @@ -688,7 +712,8 @@ export async function updateColumnType( await setTableTxTimeouts(trx, { statementMs: timeoutMs, idleMs: timeoutMs }) if (!(COLUMN_TYPES as readonly string[]).includes(data.newType)) { - throw new Error( + throw new OrchestrationError( + 'validation', `Invalid column type "${data.newType}". Valid types: ${COLUMN_TYPES.join(', ')}` ) } @@ -696,7 +721,7 @@ export async function updateColumnType( const schema = table.schema const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) if (columnIndex === -1) { - throw new Error(`Column "${data.columnName}" not found`) + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) } const column = schema.columns[columnIndex] @@ -714,7 +739,8 @@ export async function updateColumnType( data.multiple !== undefined || data.currencyCode !== undefined if (carriesOtherWork) { - throw new Error( + throw new OrchestrationError( + 'validation', `Column "${column.name}" is already type "${data.newType}"; re-issue the request without a type change.` ) } @@ -760,7 +786,8 @@ export async function updateColumnType( if (targetRequired) { const emptyCount = await countEmptyCells(trx, data.tableId, columnKey) if (emptyCount > 0) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot change column "${column.name}" to a required "${data.newType}": ${emptyCount} row(s) have null, missing, or empty values. Fill them first, or apply the type change without making the column required.` ) } @@ -828,13 +855,15 @@ export async function updateColumnType( } if (blankCount > 0) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot change column "${column.name}" to a required "${data.newType}": ${blankCount} row(s) are empty. Fill them first, or apply the type change without making the column required.` ) } if (incompatibleCount > 0) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot change column "${column.name}" to type "${data.newType}": ${incompatibleCount} row(s) have incompatible values. Fix or remove the incompatible values first.` ) } @@ -846,7 +875,10 @@ export async function updateColumnType( const columnValidation = validateColumnDefinition(updatedColumns[columnIndex]) if (!columnValidation.valid) { - throw new Error(`Invalid column: ${columnValidation.errors.join('; ')}`) + throw new OrchestrationError( + 'validation', + `Invalid column: ${columnValidation.errors.join('; ')}` + ) } const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } @@ -879,7 +911,8 @@ export async function updateColumnType( // irrecoverably rewritten. if (data.unique === true && !column.unique) { if (await hasDuplicateValues(trx, data.tableId, columnKey)) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot change column "${column.name}" to type "${data.newType}" and set it as unique: the converted values contain duplicates.` ) } @@ -926,7 +959,7 @@ export async function updateColumnConstraints( const schema = table.schema const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) if (columnIndex === -1) { - throw new Error(`Column "${data.columnName}" not found`) + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) } const column = schema.columns[columnIndex] @@ -966,12 +999,15 @@ export async function updateColumnOptions( const schema = table.schema const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) if (columnIndex === -1) { - throw new Error(`Column "${data.columnName}" not found`) + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) } const column = schema.columns[columnIndex] if (column.type !== 'select') { - throw new Error(`Cannot set options on column "${column.name}" of type "${column.type}"`) + throw new OrchestrationError( + 'validation', + `Cannot set options on column "${column.name}" of type "${column.type}"` + ) } const columnKey = getColumnId(column) @@ -984,7 +1020,10 @@ export async function updateColumnOptions( } const columnValidation = validateColumnDefinition(updatedColumn) if (!columnValidation.valid) { - throw new Error(`Invalid column: ${columnValidation.errors.join('; ')}`) + throw new OrchestrationError( + 'validation', + `Invalid column: ${columnValidation.errors.join('; ')}` + ) } const nextMultiple = !!(data.multiple ?? column.multiple) @@ -1033,7 +1072,8 @@ export async function updateColumnOptions( wasMultiple ) if (strandedCount > 0) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot remove options from required column "${column.name}": ${strandedCount} row(s) would be left empty. Reassign those rows to a remaining option first.` ) } @@ -1060,7 +1100,8 @@ export async function updateColumnOptions( } if (multiValuedCount > 0) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot switch column "${column.name}" to single-select: ${multiValuedCount} row(s) have multiple options selected. Reduce them to one option first.` ) } @@ -1132,12 +1173,15 @@ export async function updateColumnCurrency( const schema = table.schema const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) if (columnIndex === -1) { - throw new Error(`Column "${data.columnName}" not found`) + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) } const column = schema.columns[columnIndex] if (column.type !== 'currency') { - throw new Error(`Cannot set currency on column "${column.name}" of type "${column.type}"`) + throw new OrchestrationError( + 'validation', + `Cannot set currency on column "${column.name}" of type "${column.type}"` + ) } const updatedColumn: ColumnDefinition = { @@ -1146,7 +1190,10 @@ export async function updateColumnCurrency( } const columnValidation = validateColumnDefinition(updatedColumn) if (!columnValidation.valid) { - throw new Error(`Invalid column: ${columnValidation.errors.join('; ')}`) + throw new OrchestrationError( + 'validation', + `Invalid column: ${columnValidation.errors.join('; ')}` + ) } const constrained = await applyConstraints( diff --git a/apps/sim/lib/table/import-data.ts b/apps/sim/lib/table/import-data.ts index 84664e077b2..f8e5fd8d0c6 100644 --- a/apps/sim/lib/table/import-data.ts +++ b/apps/sim/lib/table/import-data.ts @@ -9,6 +9,7 @@ import { userTableDefinitions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { assertRowCapacity, notifyTableRowUsage } from '@/lib/table/billing' import { CSV_MAX_BATCH_SIZE } from '@/lib/table/import' import { assertRowDelete, assertRowInsert, assertSchemaMutable } from '@/lib/table/mutation-locks' @@ -77,11 +78,17 @@ export async function bulkInsertImportBatch( for (let i = 0; i < data.rows.length; i++) { const sizeValidation = validateRowSize(data.rows[i]) if (!sizeValidation.valid) { - throw new Error(`Row ${i + 1}: ${sizeValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${i + 1}: ${sizeValidation.errors.join(', ')}` + ) } const schemaValidation = coerceRowToSchema(data.rows[i], table.schema) if (!schemaValidation.valid) { - throw new Error(`Row ${i + 1}: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${i + 1}: ${schemaValidation.errors.join(', ')}` + ) } } @@ -94,7 +101,8 @@ export async function bulkInsertImportBatch( db ) if (!uniqueResult.valid) { - throw new Error( + throw new OrchestrationError( + 'validation', uniqueResult.errors.map((e) => `Row ${e.row + 1}: ${e.errors.join(', ')}`).join('; ') ) } diff --git a/apps/sim/lib/table/import.ts b/apps/sim/lib/table/import.ts index 02c8a6e431a..3759e4eefe0 100644 --- a/apps/sim/lib/table/import.ts +++ b/apps/sim/lib/table/import.ts @@ -12,6 +12,7 @@ */ import { type Options as CsvParseOptions, type Parser, parse as parseCsvStream } from 'csv-parse' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { getColumnId } from '@/lib/table/column-keys' import type { ColumnType } from '@/lib/table/column-types' import { parseCurrencyInput } from '@/lib/table/currency' @@ -274,11 +275,11 @@ export async function parseCsvBuffer( const parsed = parse(text, options) as unknown as Record[] if (parsed.length === 0) { - throw new Error('CSV file has no data rows') + throw new OrchestrationError('validation', 'CSV file has no data rows') } if (headers.length === 0) { - throw new Error('CSV file has no headers') + throw new OrchestrationError('validation', 'CSV file has no headers') } return { headers, rows: parsed } @@ -648,15 +649,18 @@ export function parseJsonRows(buffer: Buffer | string): { const text = typeof buffer === 'string' ? buffer : buffer.toString('utf-8') const parsed = JSON.parse(text) if (!Array.isArray(parsed)) { - throw new Error('JSON file must contain an array of objects') + throw new OrchestrationError('validation', 'JSON file must contain an array of objects') } if (parsed.length === 0) { - throw new Error('JSON file contains an empty array') + throw new OrchestrationError('validation', 'JSON file contains an empty array') } const headerSet = new Set() for (const row of parsed) { if (typeof row !== 'object' || row === null || Array.isArray(row)) { - throw new Error('Each element in the JSON array must be a plain object') + throw new OrchestrationError( + 'validation', + 'Each element in the JSON array must be a plain object' + ) } for (const key of Object.keys(row)) headerSet.add(key) } @@ -685,5 +689,8 @@ export async function parseFileRows( ) return parseCsvBuffer(buffer, delimiter) } - throw new Error(`Unsupported file format: "${ext ?? fileName}". Supported: csv, tsv, json`) + throw new OrchestrationError( + 'validation', + `Unsupported file format: "${ext ?? fileName}". Supported: csv, tsv, json` + ) } diff --git a/apps/sim/lib/table/orchestration/columns.test.ts b/apps/sim/lib/table/orchestration/columns.test.ts new file mode 100644 index 00000000000..d99eff54342 --- /dev/null +++ b/apps/sim/lib/table/orchestration/columns.test.ts @@ -0,0 +1,224 @@ +/** + * @vitest-environment node + * + * The column-update guards. These used to live in four callers (UI route, v1, + * v2, copilot tool) and had drifted apart; they are asserted here once. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table/types' + +const { + mockRenameColumn, + mockUpdateColumnType, + mockUpdateColumnOptions, + mockUpdateColumnConstraints, + mockUpdateColumnCurrency, + mockRecordAudit, +} = vi.hoisted(() => ({ + mockRenameColumn: vi.fn(), + mockUpdateColumnType: vi.fn(), + mockUpdateColumnOptions: vi.fn(), + mockUpdateColumnConstraints: vi.fn(), + mockUpdateColumnCurrency: vi.fn(), + mockRecordAudit: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_UPDATED: 'table.updated' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: mockRecordAudit, +})) + +vi.mock('@/lib/table/columns/service', () => ({ + renameColumn: mockRenameColumn, + updateColumnConstraints: mockUpdateColumnConstraints, + updateColumnCurrency: mockUpdateColumnCurrency, + updateColumnOptions: mockUpdateColumnOptions, + updateColumnType: mockUpdateColumnType, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { TableLockedError } from '@/lib/table/mutation-locks' +import { performUpdateTableColumn } from '@/lib/table/orchestration/columns' + +const SELECT_COLUMN = { + id: 'col-1', + name: 'Status', + type: 'select' as const, + options: [{ id: 'opt_open', name: 'Open' }], +} +const TEXT_COLUMN = { id: 'col-2', name: 'Priority', type: 'text' as const } + +const TABLE = { + id: 'table-1', + name: 'Tasks', + workspaceId: 'ws-1', + schema: { columns: [SELECT_COLUMN, TEXT_COLUMN] }, +} as unknown as TableDefinition + +const UPDATED = { schema: { columns: [SELECT_COLUMN] } } as unknown as TableDefinition + +function run(updates: Record, columnName = 'Status') { + return performUpdateTableColumn({ + table: TABLE, + columnName, + userId: 'user-1', + updates, + requestId: 'req-1', + }) +} + +describe('performUpdateTableColumn', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRenameColumn.mockResolvedValue(UPDATED) + mockUpdateColumnType.mockResolvedValue(UPDATED) + mockUpdateColumnOptions.mockResolvedValue(UPDATED) + mockUpdateColumnConstraints.mockResolvedValue(UPDATED) + mockUpdateColumnCurrency.mockResolvedValue(UPDATED) + }) + + it('refuses to make a select column unique before writing anything', async () => { + // Each write is its own locked transaction, so an un-gated constraint write + // commits the earlier writes and then throws, half-applying the change. + const result = await run({ unique: true }) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(mockUpdateColumnConstraints).not.toHaveBeenCalled() + }) + + it('refuses a conversion to select that is also made unique', async () => { + const result = await run({ type: 'select', options: ['Done'], unique: true }, 'Priority') + + expect(result.errorCode).toBe('validation') + expect(mockUpdateColumnType).not.toHaveBeenCalled() + }) + + it('routes an unchanged type with options to the options update', async () => { + // updateColumnType early-returns on an unchanged type and would drop them. + await run({ type: 'select', options: ['Open', 'Closed'] }) + + expect(mockUpdateColumnType).not.toHaveBeenCalled() + // Addressed by stable id so a rename folded into the write can't break it. + expect(mockUpdateColumnOptions).toHaveBeenCalledWith( + expect.objectContaining({ columnName: 'col-1' }), + 'req-1' + ) + }) + + it('reuses the id of an option resent by name so its cells survive', async () => { + await run({ options: ['Open', 'Blocked'] }) + + const [{ options }] = mockUpdateColumnOptions.mock.calls[0] + expect(options[0]).toEqual({ id: 'opt_open', name: 'Open' }) + expect(options[1].id).not.toBe('opt_open') + }) + + it('carries options and required through a real type change', async () => { + await run({ type: 'select', options: ['Done'], required: true }, 'Priority') + + expect(mockUpdateColumnOptions).not.toHaveBeenCalled() + expect(mockUpdateColumnType).toHaveBeenCalledWith( + expect.objectContaining({ newType: 'select', required: true }), + 'req-1' + ) + }) + + it('folds a rename into the write it rides on rather than running it separately', async () => { + // A rename is metadata-only, so folding it into the last write's transaction + // is what stops a combined request committing one half and failing the other. + await run({ name: 'State', required: true }) + + expect(mockRenameColumn).not.toHaveBeenCalled() + expect(mockUpdateColumnConstraints).toHaveBeenCalledWith( + expect.objectContaining({ columnName: 'col-1', newName: 'State' }), + 'req-1' + ) + }) + + it('runs a rename standalone when there is no write to ride on', async () => { + mockRenameColumn.mockResolvedValue(UPDATED) + + await run({ name: 'State' }) + + expect(mockRenameColumn).toHaveBeenCalledWith( + { tableId: 'table-1', oldName: 'col-1', newName: 'State' }, + 'req-1' + ) + }) + + it('rejects setting a currency code on a non-currency column', async () => { + const result = await run({ currencyCode: 'USD' }) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(mockUpdateColumnCurrency).not.toHaveBeenCalled() + }) + + it('rejects an unsupported currency code before any write', async () => { + const result = await run({ type: 'currency', currencyCode: 'XX' }, 'Priority') + + expect(result.errorCode).toBe('validation') + expect(mockUpdateColumnType).not.toHaveBeenCalled() + }) + + it('reports an empty payload as a validation failure', async () => { + const result = await run({}) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(result.error).toBe('No updates specified') + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it("names the type when a payload only restates the column's current type", async () => { + const result = await run({ type: 'select' }) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(result.error).toContain('is already type "select"') + expect(mockUpdateColumnType).not.toHaveBeenCalled() + }) + + it('classifies a table lock as locked and does not audit', async () => { + mockUpdateColumnConstraints.mockRejectedValue(new TableLockedError('update')) + + const result = await run({ required: true }) + + expect(result.errorCode).toBe('locked') + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('reports the code the service failure carries', async () => { + mockUpdateColumnConstraints.mockRejectedValue( + new OrchestrationError('validation', 'Column "State" already exists') + ) + + expect((await run({ required: true })).errorCode).toBe('validation') + }) + + it('classifies a missing column as not_found', async () => { + mockUpdateColumnConstraints.mockRejectedValue( + new OrchestrationError('not_found', 'Column "Nope" not found') + ) + + expect((await run({ required: true })).errorCode).toBe('not_found') + }) + + it('keeps an unclassified fault internal and hides its message', async () => { + // Wording alone must never buy a status: this reads exactly like the + // caller-fixable failure above but carries no classification. + mockUpdateColumnConstraints.mockRejectedValue(new Error('Column "State" already exists')) + + const result = await run({ required: true }) + + expect(result.errorCode).toBe('internal') + expect(result.error).toBe('Failed to update column') + }) + + it('audits a successful update on every caller', async () => { + // The UI route and the copilot tool previously emitted no audit at all. + await run({ required: true }) + + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: 'ws-1', actorId: 'user-1', resourceId: 'table-1' }) + ) + }) +}) diff --git a/apps/sim/lib/table/orchestration/columns.ts b/apps/sim/lib/table/orchestration/columns.ts new file mode 100644 index 00000000000..18d321d3e3b --- /dev/null +++ b/apps/sim/lib/table/orchestration/columns.ts @@ -0,0 +1,266 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { + OrchestrationError, + type OrchestrationErrorCode, + type OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys' +import { columnTypeById } from '@/lib/table/column-types' +import { + renameColumn, + updateColumnConstraints, + updateColumnCurrency, + updateColumnOptions, + updateColumnType, +} from '@/lib/table/columns/service' +import { isSupportedCurrencyCode } from '@/lib/table/currency' +import { TableLockedError } from '@/lib/table/mutation-locks' +import { normalizeSelectOptionsInput } from '@/lib/table/select-options' +import type { ColumnType, SelectOption, TableDefinition } from '@/lib/table/types' + +const logger = createLogger('TableColumnOrchestration') + +export interface PerformUpdateTableColumnParams { + table: TableDefinition + columnName: string + userId: string + updates: { + name?: string + type?: ColumnType + required?: boolean + unique?: boolean + /** Accepts `{id,name}` pairs or bare names; ids are minted/reused as needed. */ + options?: unknown + multiple?: boolean + currencyCode?: string + } + requestId?: string + /** Forwarded to the audit record for IP / user-agent capture. */ + request?: OrchestrationRequestContext +} + +export interface PerformUpdateTableColumnResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + table?: TableDefinition +} + +function classify(error: unknown): PerformUpdateTableColumnResult { + if (error instanceof TableLockedError) { + return { success: false, error: error.message, errorCode: 'locked' } + } + if (error instanceof OrchestrationError) { + return { success: false, error: error.message, errorCode: error.code } + } + return { success: false, error: 'Failed to update column', errorCode: 'internal' } +} + +function fail(error: string, errorCode: OrchestrationErrorCode): PerformUpdateTableColumnResult { + return { success: false, error, errorCode } +} + +/** + * Applies a column update — rename, type conversion, currency re-denomination, + * option-set edit, and constraint change — as the single implementation behind + * the UI route, the v1 and v2 public APIs, and the copilot table tool. + * + * Each underlying write is its own locked transaction, so a request that spans + * several of them could commit one and then fail. Two things prevent that and + * both are load-bearing: the guards below reject every knowable failure before + * any write runs, and the rename and constraint changes ride *inside* the typed + * write's transaction rather than following it. The caller owns authentication + * and workspace scoping; by the time this runs, `table` is one the actor may write. + */ +export async function performUpdateTableColumn( + params: PerformUpdateTableColumnParams +): Promise { + const { table, columnName, userId, updates, request } = params + const requestId = params.requestId ?? generateRequestId() + const tableId = table.id + + const currentColumn = table.schema.columns.find((c) => columnMatchesRef(c, columnName)) + if (!currentColumn) { + return fail(`Column "${columnName}" not found`, 'not_found') + } + + // Address every write by the stable id, not the name: a rename folded into + // one of them must not break the next one's lookup. + const columnRef = getColumnId(currentColumn) + const existingOptions: SelectOption[] = currentColumn.options ?? [] + const options = normalizeSelectOptionsInput(updates.options, existingOptions) + + // 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. + const typeChanging = updates.type !== undefined && updates.type !== currentColumn.type + + // A retype applies and validates the constraints itself, so the separate + // constraint write only runs when no typed write does. The rename rides + // whichever write actually runs last. + const typedWriteRuns = + typeChanging || + updates.currencyCode !== undefined || + options !== undefined || + updates.multiple !== undefined + const constraintsWriteRuns = + !typedWriteRuns && (updates.required !== undefined || updates.unique !== undefined) + const renameWithTypedWrite = + updates.name && !constraintsWriteRuns ? { newName: updates.name } : {} + + // 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 fail( + `Cannot set currency on column "${columnName}" of type "${resultingType}"`, + 'validation' + ) + } + if (!isSupportedCurrencyCode(updates.currencyCode)) { + return fail( + `Invalid currency code "${updates.currencyCode}". Use an ISO 4217 code, e.g. USD`, + 'validation' + ) + } + } + // The rename runs last, 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. + if ( + updates.name && + table.schema.columns.some( + (c) => + c.name.toLowerCase() === updates.name?.toLowerCase() && !columnMatchesRef(c, columnName) + ) + ) { + return fail(`Column "${updates.name}" already exists`, 'validation') + } + if ( + currentColumn.workflowGroupId && + (updates.required !== undefined || updates.unique !== undefined) + ) { + return fail( + `Cannot change constraints on workflow-output column "${currentColumn.name}". Constraints aren't applicable to columns whose values come from workflow execution.`, + 'validation' + ) + } + if (updates.unique === true && !columnTypeById(resultingType).supportsUnique) { + return fail(`Cannot set a ${resultingType} column as unique`, 'validation') + } + + let updated: TableDefinition | undefined + + try { + if (typeChanging) { + updated = await updateColumnType( + { + tableId, + columnName: columnRef, + newType: updates.type as ColumnType, + ...(options !== undefined ? { options } : {}), + ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), + ...(updates.currencyCode !== undefined ? { currencyCode: updates.currencyCode } : {}), + // Forwarded so the conversion validates against the constraints this + // same request is about to set, not the column's current ones. + ...(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. + updated = await updateColumnCurrency( + { + tableId, + columnName: columnRef, + currencyCode: updates.currencyCode, + ...(updates.required !== undefined ? { required: updates.required } : {}), + ...(updates.unique !== undefined ? { unique: updates.unique } : {}), + ...renameWithTypedWrite, + }, + requestId + ) + } else if (options !== undefined || updates.multiple !== undefined) { + updated = await updateColumnOptions( + { + tableId, + columnName: columnRef, + options: options ?? existingOptions, + ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), + ...(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) { + updated = await updateColumnConstraints( + { + tableId, + columnName: columnRef, + ...(updates.required !== undefined ? { required: updates.required } : {}), + ...(updates.unique !== undefined ? { unique: updates.unique } : {}), + ...(updates.name ? { newName: updates.name } : {}), + }, + requestId + ) + } + + // 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 && !updated) { + updated = await renameColumn( + { tableId, oldName: columnRef, newName: updates.name }, + requestId + ) + } + } catch (error) { + logger.error(`[${requestId}] Failed to update column "${columnName}" on table ${tableId}`, { + error, + }) + return classify(error) + } + + if (!updated) { + // A payload whose only content is the type the column already has names a + // change and asks for nothing. Say which, the way `updateColumnType` does + // when it loses the same race, rather than claiming the request was empty. + if (updates.type !== undefined) { + return fail( + `Column "${currentColumn.name}" is already type "${currentColumn.type}"; re-issue the request without a type change.`, + 'validation' + ) + } + return fail('No updates specified', 'validation') + } + + recordAudit({ + workspaceId: table.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Updated column "${columnName}" in table "${table.name}"`, + metadata: { columnName, updates }, + ...(request ? { request } : {}), + }) + + return { success: true, table: updated } +} diff --git a/apps/sim/lib/table/orchestration/index.ts b/apps/sim/lib/table/orchestration/index.ts index af29da6aced..b1ea82abddf 100644 --- a/apps/sim/lib/table/orchestration/index.ts +++ b/apps/sim/lib/table/orchestration/index.ts @@ -1,64 +1,9 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { generateRequestId } from '@/lib/core/utils/request' -import { getTableById, restoreTable, TableConflictError } from '@/lib/table/service' -import type { TableDefinition } from '@/lib/table/types' - -const logger = createLogger('TableOrchestration') - -export type TableOrchestrationErrorCode = 'not_found' | 'validation' | 'conflict' | 'internal' - -export interface PerformRestoreTableParams { - tableId: string - userId: string - requestId?: string -} - -export interface PerformRestoreTableResult { - success: boolean - error?: string - errorCode?: TableOrchestrationErrorCode - table?: TableDefinition -} - -export async function performRestoreTable( - params: PerformRestoreTableParams -): Promise { - const { tableId, userId } = params - const requestId = params.requestId ?? generateRequestId() - - const archivedTable = await getTableById(tableId, { includeArchived: true }) - if (!archivedTable) { - return { success: false, error: 'Table not found', errorCode: 'not_found' } - } - - try { - await restoreTable(tableId, requestId) - const table = (await getTableById(tableId)) ?? archivedTable - - logger.info(`[${requestId}] Restored table ${tableId}`) - - recordAudit({ - workspaceId: archivedTable.workspaceId, - actorId: userId, - action: AuditAction.TABLE_RESTORED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: table.name, - description: `Restored table "${table.name}"`, - metadata: { - tableName: table.name, - workspaceId: table.workspaceId, - }, - }) - - return { success: true, table } - } catch (error) { - logger.error(`[${requestId}] Failed to restore table ${tableId}`, { error }) - if (error instanceof TableConflictError) { - return { success: false, error: error.message, errorCode: 'conflict' } - } - return { success: false, error: toError(error).message, errorCode: 'internal' } - } -} +export { performUpdateTableColumn } from './columns' +export { performRestoreTable } from './restore' +export { + performDeleteTable, + performDeleteTableRow, + performMoveTableToFolder, + performRenameTable, + performUpdateTableLocks, +} from './tables' diff --git a/apps/sim/lib/table/orchestration/restore.ts b/apps/sim/lib/table/orchestration/restore.ts new file mode 100644 index 00000000000..1aff4de5da2 --- /dev/null +++ b/apps/sim/lib/table/orchestration/restore.ts @@ -0,0 +1,63 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { getTableById, restoreTable, TableConflictError } from '@/lib/table/service' +import type { TableDefinition } from '@/lib/table/types' + +const logger = createLogger('TableOrchestration') + +export interface PerformRestoreTableParams { + tableId: string + userId: string + requestId?: string +} + +export interface PerformRestoreTableResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + table?: TableDefinition +} + +export async function performRestoreTable( + params: PerformRestoreTableParams +): Promise { + const { tableId, userId } = params + const requestId = params.requestId ?? generateRequestId() + + const archivedTable = await getTableById(tableId, { includeArchived: true }) + if (!archivedTable) { + return { success: false, error: 'Table not found', errorCode: 'not_found' } + } + + try { + await restoreTable(tableId, requestId) + const table = (await getTableById(tableId)) ?? archivedTable + + logger.info(`[${requestId}] Restored table ${tableId}`) + + recordAudit({ + workspaceId: archivedTable.workspaceId, + actorId: userId, + action: AuditAction.TABLE_RESTORED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Restored table "${table.name}"`, + metadata: { + tableName: table.name, + workspaceId: table.workspaceId, + }, + }) + + return { success: true, table } + } catch (error) { + logger.error(`[${requestId}] Failed to restore table ${tableId}`, { error }) + if (error instanceof TableConflictError) { + return { success: false, error: error.message, errorCode: 'conflict' } + } + return { success: false, error: toError(error).message, errorCode: 'internal' } + } +} diff --git a/apps/sim/lib/table/orchestration/tables.test.ts b/apps/sim/lib/table/orchestration/tables.test.ts new file mode 100644 index 00000000000..09127e8e40b --- /dev/null +++ b/apps/sim/lib/table/orchestration/tables.test.ts @@ -0,0 +1,142 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table/types' + +const { mockDeleteTable, mockDeleteRow, mockRenameTable, mockCaptureServerEvent, mockRecordAudit } = + vi.hoisted(() => ({ + mockDeleteTable: vi.fn(), + mockDeleteRow: vi.fn(), + mockRenameTable: vi.fn(), + mockCaptureServerEvent: vi.fn(), + mockRecordAudit: vi.fn(), + })) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_DELETED: 'table.deleted', TABLE_UPDATED: 'table.updated' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: mockRecordAudit, +})) + +vi.mock('@/lib/table/service', () => ({ + deleteTable: mockDeleteTable, + moveTableToFolder: vi.fn(), + renameTable: mockRenameTable, + updateTableLocks: vi.fn(), +})) +vi.mock('@/lib/table/rows/service', () => ({ deleteRow: mockDeleteRow })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent })) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { TableLockedError } from '@/lib/table/mutation-locks' +import { + performDeleteTable, + performDeleteTableRow, + performRenameTable, +} from '@/lib/table/orchestration/tables' + +const TABLE = { id: 'table-1', name: 'Tasks', workspaceId: 'ws-1' } as unknown as TableDefinition + +describe('performDeleteTable', () => { + beforeEach(() => vi.clearAllMocks()) + + it('audits a genuine archive against the acting user', async () => { + mockDeleteTable.mockResolvedValue({ archived: { name: 'Tasks', workspaceId: 'ws-1' } }) + + const result = await performDeleteTable({ table: TABLE, userId: 'user-1', requestId: 'req-1' }) + + expect(result.success).toBe(true) + // The service no longer takes an actor — auditing follows from a user + // performing the operation, not from which function the caller reached for. + expect(mockDeleteTable).toHaveBeenCalledWith('table-1', 'req-1') + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ actorId: 'user-1', resourceId: 'table-1' }) + ) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'table_deleted', + expect.objectContaining({ table_id: 'table-1' }), + expect.anything() + ) + }) + + it('carries request provenance into the audit row', async () => { + mockDeleteTable.mockResolvedValue({ archived: { name: 'Tasks', workspaceId: 'ws-1' } }) + const request = new Request('https://sim.ai', { headers: { 'user-agent': 'curl/8' } }) + + await performDeleteTable({ table: TABLE, userId: 'user-1', request }) + + expect(mockRecordAudit).toHaveBeenCalledWith(expect.objectContaining({ request })) + }) + + it('neither audits nor reports a repeat delete of an already-archived table', async () => { + mockDeleteTable.mockResolvedValue({ archived: null }) + + const result = await performDeleteTable({ table: TABLE, userId: 'user-1' }) + + expect(result.success).toBe(true) + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) + + it('classifies a delete lock as locked and emits no telemetry', async () => { + mockDeleteTable.mockRejectedValue(new TableLockedError('delete')) + + const result = await performDeleteTable({ table: TABLE, userId: 'user-1' }) + + expect(result).toMatchObject({ success: false, errorCode: 'locked' }) + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) +}) + +describe('performRenameTable', () => { + beforeEach(() => vi.clearAllMocks()) + + it('classifies a name collision as a conflict, not bad input', async () => { + // `TableConflictError` is an `OrchestrationError('conflict')` — the class + // decides the status, so the 409 no longer rides on the message wording. + mockRenameTable.mockRejectedValue( + new OrchestrationError('conflict', 'A table named "Tasks" already exists in this workspace') + ) + + const result = await performRenameTable({ table: TABLE, newName: 'Tasks', userId: 'user-1' }) + + expect(result).toMatchObject({ success: false, errorCode: 'conflict' }) + }) + + it('keeps an unclassified rename failure internal', async () => { + mockRenameTable.mockRejectedValue(new Error('A table named "Tasks" already exists')) + + expect( + (await performRenameTable({ table: TABLE, newName: 'Tasks', userId: 'user-1' })).errorCode + ).toBe('internal') + }) +}) + +describe('performDeleteTableRow', () => { + beforeEach(() => vi.clearAllMocks()) + + it('deletes through the row service so the lock and bookkeeping apply', async () => { + mockDeleteRow.mockResolvedValue(undefined) + + const result = await performDeleteTableRow({ table: TABLE, rowId: 'row-1', requestId: 'req-1' }) + + expect(result.success).toBe(true) + expect(mockDeleteRow).toHaveBeenCalledWith(TABLE, 'row-1', 'req-1') + }) + + it('classifies a delete lock as locked', async () => { + mockDeleteRow.mockRejectedValue(new TableLockedError('delete')) + + expect((await performDeleteTableRow({ table: TABLE, rowId: 'row-1' })).errorCode).toBe('locked') + }) + + it('classifies a missing row as not_found', async () => { + mockDeleteRow.mockRejectedValue(new OrchestrationError('not_found', 'Row not found')) + + expect((await performDeleteTableRow({ table: TABLE, rowId: 'row-1' })).errorCode).toBe( + 'not_found' + ) + }) +}) diff --git a/apps/sim/lib/table/orchestration/tables.ts b/apps/sim/lib/table/orchestration/tables.ts new file mode 100644 index 00000000000..dcec50a25cc --- /dev/null +++ b/apps/sim/lib/table/orchestration/tables.ts @@ -0,0 +1,274 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { + OrchestrationError, + type OrchestrationErrorCode, + type OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { captureServerEvent } from '@/lib/posthog/server' +import { TableLockedError } from '@/lib/table/mutation-locks' +import { deleteRow } from '@/lib/table/rows/service' +import { deleteTable, moveTableToFolder, renameTable, updateTableLocks } from '@/lib/table/service' +import { + TABLE_LOCK_FLAGS, + TABLE_LOCK_KINDS, + type TableDefinition, + type TableLocks, +} from '@/lib/table/types' + +const logger = createLogger('TableOrchestration') + +export interface PerformDeleteTableParams { + table: TableDefinition + userId: string + requestId?: string + /** Forwarded to the audit record for IP / user-agent capture. */ + request?: OrchestrationRequestContext +} + +export interface PerformDeleteTableResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode +} + +/** + * Archives a table on behalf of `userId`. + * + * The audit lives here rather than in `deleteTable` so that auditing follows + * from "a user performed this operation", not from which function a caller + * reached for. The rollback and cleanup paths call the service directly and + * are silent by construction, and a repeat delete of an already-archived table + * logs nothing because the service reports that it archived no row. + */ +export async function performDeleteTable( + params: PerformDeleteTableParams +): Promise { + const { table, userId, request } = params + const requestId = params.requestId ?? generateRequestId() + + let archived: { name: string; workspaceId: string | null } | null + try { + ;({ archived } = await deleteTable(table.id, requestId)) + } catch (error) { + if (error instanceof TableLockedError) { + return { success: false, error: error.message, errorCode: 'locked' } + } + if (error instanceof OrchestrationError) { + return { success: false, error: error.message, errorCode: error.code } + } + logger.error(`[${requestId}] Failed to delete table ${table.id}`, { error }) + return { success: false, error: toError(error).message, errorCode: 'internal' } + } + + // Both the audit and the analytics event describe an archive that happened, so + // both hang off the same evidence that one did. A repeat delete of an + // already-archived table succeeds and records nothing. + if (archived) { + recordAudit({ + workspaceId: archived.workspaceId, + actorId: userId, + action: AuditAction.TABLE_DELETED, + resourceType: AuditResourceType.TABLE, + resourceId: table.id, + resourceName: archived.name, + description: `Archived table "${archived.name}"`, + ...(request ? { request } : {}), + }) + captureServerEvent( + userId, + 'table_deleted', + { table_id: table.id, workspace_id: table.workspaceId }, + { groups: { workspace: table.workspaceId } } + ) + } + + return { success: true } +} + +export interface PerformDeleteTableRowParams { + table: TableDefinition + rowId: string + requestId?: string +} + +export interface PerformDeleteTableRowResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode +} + +/** + * Deletes a single row through the row service, so the delete lock is enforced + * and the row-count and ordering bookkeeping runs. A raw `db.delete` skips both + * and returns success on a locked table. + */ +export async function performDeleteTableRow( + params: PerformDeleteTableRowParams +): Promise { + const { table, rowId } = params + const requestId = params.requestId ?? generateRequestId() + + try { + await deleteRow(table, rowId, requestId) + return { success: true } + } catch (error) { + if (error instanceof TableLockedError) { + return { success: false, error: error.message, errorCode: 'locked' } + } + if (error instanceof OrchestrationError) { + return { success: false, error: error.message, errorCode: error.code } + } + logger.error(`[${requestId}] Failed to delete row ${rowId} from table ${table.id}`, { error }) + return { success: false, error: toError(error).message, errorCode: 'internal' } + } +} + +export interface PerformRenameTableParams { + table: TableDefinition + newName: string + userId: string + requestId?: string + /** Forwarded to the audit record for IP / user-agent capture. */ + request?: OrchestrationRequestContext +} + +export interface PerformTableMutationResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + table?: TableDefinition +} + +function classifyTableMutation(error: unknown, requestId: string, tableId: string) { + if (error instanceof TableLockedError) { + return { success: false as const, error: error.message, errorCode: 'locked' as const } + } + // `TableConflictError` is an `OrchestrationError('conflict')`, so a duplicate + // rename reaches 409 through this branch — by class, not by the message + // happening to contain "already exists". + if (error instanceof OrchestrationError) { + return { success: false as const, error: error.message, errorCode: error.code } + } + logger.error(`[${requestId}] Table mutation failed for ${tableId}`, { error }) + return { + success: false as const, + error: toError(error).message, + errorCode: 'internal' as const, + } +} + +/** Renames a table and records the rename against `userId`. */ +export async function performRenameTable( + params: PerformRenameTableParams +): Promise { + const { table, newName, userId, request } = params + const requestId = params.requestId ?? generateRequestId() + + try { + const renamed = await renameTable(table.id, newName, requestId) + recordAudit({ + workspaceId: table.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: table.id, + resourceName: renamed.name, + description: `Renamed table to "${renamed.name}"`, + metadata: { op: 'rename', previousName: table.name }, + ...(request ? { request } : {}), + }) + return { success: true } + } catch (error) { + return classifyTableMutation(error, requestId, table.id) + } +} + +export interface PerformMoveTableParams { + table: TableDefinition + folderId: string | null + userId: string + requestId?: string + /** Forwarded to the audit record for IP / user-agent capture. */ + request?: OrchestrationRequestContext +} + +/** Moves a table between folders (or to the workspace root). */ +export async function performMoveTableToFolder( + params: PerformMoveTableParams +): Promise { + const { table, folderId, userId, request } = params + const requestId = params.requestId ?? generateRequestId() + if (!table.workspaceId) { + return { success: false, error: 'Table is not in a workspace', errorCode: 'validation' } + } + + try { + const { name } = await moveTableToFolder(table.id, table.workspaceId, folderId, requestId) + recordAudit({ + workspaceId: table.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: table.id, + resourceName: name, + description: folderId + ? `Moved table "${name}" into a folder` + : `Moved table "${name}" to the workspace root`, + metadata: { op: 'move', folderId }, + ...(request ? { request } : {}), + }) + return { success: true } + } catch (error) { + return classifyTableMutation(error, requestId, table.id) + } +} + +export interface PerformUpdateTableLocksParams { + tableId: string + partial: Partial + userId: string + requestId?: string + /** Forwarded to the audit record for IP / user-agent capture. */ + request?: OrchestrationRequestContext +} + +/** + * Applies a lock change and names the transitions in the audit description, so + * the audit list answers "who locked my production table" without expanding + * metadata. The before/after state comes back from the service because only the + * locked write can observe it. + */ +export async function performUpdateTableLocks( + params: PerformUpdateTableLocksParams +): Promise { + const { tableId, partial, userId, request } = params + const requestId = params.requestId ?? generateRequestId() + + try { + const { table, previousLocks } = await updateTableLocks(tableId, partial, requestId) + const flipped = TABLE_LOCK_KINDS.filter( + (kind) => previousLocks[TABLE_LOCK_FLAGS[kind]] !== table.locks[TABLE_LOCK_FLAGS[kind]] + ) + recordAudit({ + workspaceId: table.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: flipped.length + ? `Table locks changed: ${flipped + .map((kind) => `${kind} ${table.locks[TABLE_LOCK_FLAGS[kind]] ? 'locked' : 'unlocked'}`) + .join(', ')}` + : 'Updated table locks (no change)', + metadata: { op: 'update_locks', before: previousLocks, after: table.locks }, + ...(request ? { request } : {}), + }) + return { success: true, table } + } catch (error) { + return classifyTableMutation(error, requestId, tableId) + } +} diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index 2cf9e357b63..5637daad5b5 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -16,6 +16,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, count, eq, inArray, lte, notInArray, type SQL, sql } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { assertRowCapacity, getMaxRowsPerTable, @@ -124,13 +125,16 @@ export async function insertRow( // Validate row size const sizeValidation = validateRowSize(data.data) if (!sizeValidation.valid) { - throw new Error(sizeValidation.errors.join(', ')) + throw new OrchestrationError('validation', sizeValidation.errors.join(', ')) } // Validate against schema const schemaValidation = coerceRowToSchema(data.data, table.schema) if (!schemaValidation.valid) { - throw new Error(`Schema validation failed: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Schema validation failed: ${schemaValidation.errors.join(', ')}` + ) } // Check unique constraints using optimized database query @@ -138,7 +142,7 @@ export async function insertRow( if (uniqueColumns.length > 0) { const uniqueValidation = await checkUniqueConstraintsDb(data.tableId, data.data, table.schema) if (!uniqueValidation.valid) { - throw new Error(uniqueValidation.errors.join(', ')) + throw new OrchestrationError('validation', uniqueValidation.errors.join(', ')) } } @@ -261,12 +265,18 @@ export async function batchInsertRowsWithTx( const sizeValidation = validateRowSize(row) if (!sizeValidation.valid) { - throw new Error(`Row ${i + 1}: ${sizeValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${i + 1}: ${sizeValidation.errors.join(', ')}` + ) } const schemaValidation = coerceRowToSchema(row, table.schema) if (!schemaValidation.valid) { - throw new Error(`Row ${i + 1}: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${i + 1}: ${schemaValidation.errors.join(', ')}` + ) } } @@ -282,7 +292,7 @@ export async function batchInsertRowsWithTx( const errorMessages = uniqueResult.errors .map((e) => `Row ${e.row + 1}: ${e.errors.join(', ')}`) .join('; ') - throw new Error(errorMessages) + throw new OrchestrationError('validation', errorMessages) } } @@ -422,12 +432,18 @@ export async function replaceTableRowsWithTx( const sizeValidation = validateRowSize(row) if (!sizeValidation.valid) { - throw new Error(`Row ${i + 1}: ${sizeValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${i + 1}: ${sizeValidation.errors.join(', ')}` + ) } const schemaValidation = coerceRowToSchema(row, table.schema) if (!schemaValidation.valid) { - throw new Error(`Row ${i + 1}: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${i + 1}: ${schemaValidation.errors.join(', ')}` + ) } } @@ -449,7 +465,8 @@ export async function replaceTableRowsWithTx( const normalized = typeof value === 'string' ? value : JSON.stringify(value) const map = seen.get(colId)! if (map.has(normalized)) { - throw new Error( + throw new OrchestrationError( + 'validation', `Row ${i + 1}: Column "${col.name}" must be unique. Value "${String(value)}" duplicates row ${map.get(normalized)! + 1} in batch` ) } @@ -538,7 +555,8 @@ export async function upsertRow( const uniqueColumns = getUniqueColumns(schema) if (uniqueColumns.length === 0) { - throw new Error( + throw new OrchestrationError( + 'validation', 'Upsert requires at least one unique column in the schema. Please add a unique constraint to a column or use insert instead.' ) } @@ -552,7 +570,8 @@ export async function upsertRow( (c) => getColumnId(c) === data.conflictTarget || c.name === data.conflictTarget ) if (!col) { - throw new Error( + throw new OrchestrationError( + 'validation', `Column "${data.conflictTarget}" is not a unique column. Available unique columns: ${uniqueColumns.map((c) => c.name).join(', ')}` ) } @@ -560,7 +579,8 @@ export async function upsertRow( } else if (uniqueColumns.length === 1) { targetColumnKey = getColumnId(uniqueColumns[0]) } else { - throw new Error( + throw new OrchestrationError( + 'validation', `Table has multiple unique columns (${uniqueColumns.map((c) => c.name).join(', ')}). Specify a conflict column to indicate which one to match on.` ) } @@ -568,12 +588,15 @@ export async function upsertRow( // Validate row data const sizeValidation = validateRowSize(data.data) if (!sizeValidation.valid) { - throw new Error(sizeValidation.errors.join(', ')) + throw new OrchestrationError('validation', sizeValidation.errors.join(', ')) } const schemaValidation = coerceRowToSchema(data.data, schema) if (!schemaValidation.valid) { - throw new Error(`Schema validation failed: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Schema validation failed: ${schemaValidation.errors.join(', ')}` + ) } // Read the conflict-target value *after* coercion so `matchFilter` branches on @@ -583,7 +606,10 @@ export async function upsertRow( // Surface the display name, not the internal id — v1 callers pass a name. const targetColumnName = uniqueColumns.find((c) => getColumnId(c) === targetColumnKey)?.name ?? targetColumnKey - throw new Error(`Upsert requires a value for the conflict target column "${targetColumnName}"`) + throw new OrchestrationError( + 'validation', + `Upsert requires a value for the conflict target column "${targetColumnName}"` + ) } // Build the conflict probe through the SAME leaf as the unique-constraint check @@ -636,7 +662,10 @@ export async function upsertRow( trx ) if (!uniqueValidation.valid) { - throw new Error(`Unique constraint violation: ${uniqueValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Unique constraint violation: ${uniqueValidation.errors.join(', ')}` + ) } const now = new Date() @@ -1429,7 +1458,7 @@ export async function updateRow( // Get existing row const existingRow = await getRowById(data.tableId, data.rowId, data.workspaceId) if (!existingRow) { - throw new Error('Row not found') + throw new OrchestrationError('not_found', 'Row not found') } // Merge partial update with existing row data so callers can pass only changed fields @@ -1453,13 +1482,16 @@ export async function updateRow( // Validate size const sizeValidation = validateRowSize(mergedData) if (!sizeValidation.valid) { - throw new Error(sizeValidation.errors.join(', ')) + throw new OrchestrationError('validation', sizeValidation.errors.join(', ')) } // Validate against schema const schemaValidation = coerceRowToSchema(mergedData, table.schema) if (!schemaValidation.valid) { - throw new Error(`Schema validation failed: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Schema validation failed: ${schemaValidation.errors.join(', ')}` + ) } // Check unique constraints using optimized database query @@ -1472,7 +1504,7 @@ export async function updateRow( data.rowId // Exclude current row ) if (!uniqueValidation.valid) { - throw new Error(uniqueValidation.errors.join(', ')) + throw new OrchestrationError('validation', uniqueValidation.errors.join(', ')) } } @@ -1612,7 +1644,7 @@ export async function deleteRow( workspaceId: table.workspaceId, proof, }) - if (!deleted) throw new Error('Row not found') + if (!deleted) throw new OrchestrationError('not_found', 'Row not found') logger.info(`[${requestId}] Deleted row ${rowId} from table ${table.id}`) } @@ -1636,7 +1668,7 @@ export async function updateRowsByFilter( const filterClause = buildFilterClause(data.filter, tableName, table.schema.columns) if (!filterClause) { - throw new Error('Filter is required for bulk update') + throw new OrchestrationError('validation', 'Filter is required for bulk update') } const baseConditions = and( @@ -1678,12 +1710,18 @@ export async function updateRowsByFilter( const sizeValidation = validateRowSize(mergedData) if (!sizeValidation.valid) { - throw new Error(`Row ${row.id}: ${sizeValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${row.id}: ${sizeValidation.errors.join(', ')}` + ) } const schemaValidation = coerceRowToSchema(mergedData, table.schema) if (!schemaValidation.valid) { - throw new Error(`Row ${row.id}: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${row.id}: ${schemaValidation.errors.join(', ')}` + ) } } @@ -1691,7 +1729,8 @@ export async function updateRowsByFilter( const uniqueColumnsInUpdate = uniqueColumns.filter((col) => col.name in data.data) if (uniqueColumnsInUpdate.length > 0) { if (matchingRows.length > 1) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot set unique column values when updating multiple rows. ` + `Columns with unique constraint: ${uniqueColumnsInUpdate.map((c) => c.name).join(', ')}. ` + `Updating ${matchingRows.length} rows with the same value would violate uniqueness.` @@ -1708,7 +1747,10 @@ export async function updateRowsByFilter( row.id ) if (!uniqueValidation.valid) { - throw new Error(`Unique constraint violation: ${uniqueValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Unique constraint violation: ${uniqueValidation.errors.join(', ')}` + ) } } @@ -1826,7 +1868,7 @@ export async function batchUpdateRows( const missing = rowIds.filter((id) => !existingMap.has(id)) if (missing.length > 0) { - throw new Error(`Rows not found: ${missing.join(', ')}`) + throw new OrchestrationError('validation', `Rows not found: ${missing.join(', ')}`) } const mergedUpdates: Array<{ @@ -1856,12 +1898,18 @@ export async function batchUpdateRows( const sizeValidation = validateRowSize(merged) if (!sizeValidation.valid) { - throw new Error(`Row ${update.rowId}: ${sizeValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${update.rowId}: ${sizeValidation.errors.join(', ')}` + ) } const schemaValidation = coerceRowToSchema(merged, table.schema) if (!schemaValidation.valid) { - throw new Error(`Row ${update.rowId}: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${update.rowId}: ${schemaValidation.errors.join(', ')}` + ) } mergedUpdates.push({ @@ -1884,7 +1932,10 @@ export async function batchUpdateRows( rowId ) if (!uniqueValidation.valid) { - throw new Error(`Row ${rowId}: ${uniqueValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${rowId}: ${uniqueValidation.errors.join(', ')}` + ) } } } @@ -2006,7 +2057,7 @@ export async function deleteRowsByFilter( // Build filter clause const filterClause = buildFilterClause(data.filter, tableName, table.schema.columns) if (!filterClause) { - throw new Error('Filter is required for bulk delete') + throw new OrchestrationError('validation', 'Filter is required for bulk delete') } // Find matching rows diff --git a/apps/sim/lib/table/select-options.test.ts b/apps/sim/lib/table/select-options.test.ts new file mode 100644 index 00000000000..36841dc9926 --- /dev/null +++ b/apps/sim/lib/table/select-options.test.ts @@ -0,0 +1,59 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { normalizeSelectOptionsInput } from '@/lib/table/select-options' + +describe('normalizeSelectOptionsInput', () => { + it('generates a stable id for a bare-name string option', () => { + const [opt] = normalizeSelectOptionsInput(['Open']) ?? [] + expect(opt.name).toBe('Open') + expect(typeof opt.id).toBe('string') + expect(opt.id.length).toBeGreaterThan(0) + }) + + it('generates an id for an object option without one', () => { + const [opt] = normalizeSelectOptionsInput([{ name: 'Closed' }]) ?? [] + expect(opt.name).toBe('Closed') + expect(opt.id.length).toBeGreaterThan(0) + }) + + it('preserves an explicitly supplied id', () => { + const result = normalizeSelectOptionsInput([{ id: 'opt_keep', name: 'Open' }]) + expect(result).toEqual([{ id: 'opt_keep', name: 'Open' }]) + }) + + it('reuses the id of an existing option with the same name', () => { + // The agent re-sends options as bare names on every edit. Minting fresh ids + // would orphan every cell holding them — silently clearing the column. + const existing = [ + { id: 'opt_low', name: 'Low' }, + { id: 'opt_high', name: 'High' }, + ] + const result = normalizeSelectOptionsInput(['Low', 'Medium', 'High'], existing) ?? [] + + expect(result[0]).toEqual({ id: 'opt_low', name: 'Low' }) + expect(result[2]).toEqual({ id: 'opt_high', name: 'High' }) + // Only the genuinely new option gets a fresh id. + expect(result[1].name).toBe('Medium') + expect(result[1].id).not.toBe('opt_low') + expect(result[1].id).not.toBe('opt_high') + }) + + it('matches an existing option name case-insensitively', () => { + const result = normalizeSelectOptionsInput(['open'], [{ id: 'opt_open', name: 'Open' }]) ?? [] + expect(result[0].id).toBe('opt_open') + expect(result[0].name).toBe('open') + }) + + it('mints a fresh id when there is no existing column to match against', () => { + const result = normalizeSelectOptionsInput(['Open']) ?? [] + expect(result[0].id.length).toBeGreaterThan(0) + expect(result[0].name).toBe('Open') + }) + + it('returns undefined for a non-array (validation rejects it downstream)', () => { + expect(normalizeSelectOptionsInput(undefined)).toBeUndefined() + expect(normalizeSelectOptionsInput('Open')).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/table/select-options.ts b/apps/sim/lib/table/select-options.ts index aa62cfa0bfd..332b465cb7d 100644 --- a/apps/sim/lib/table/select-options.ts +++ b/apps/sim/lib/table/select-options.ts @@ -12,6 +12,7 @@ * Keeping the option primitives here lets both sides share one implementation. */ +import { generateShortId } from '@sim/utils/id' import type { ColumnDefinition, JsonValue, SelectOption } from '@/lib/table/types' /** Set of valid option ids for a `select`/`multiselect` column. */ @@ -85,3 +86,34 @@ export function resolveSelectCellValue(value: JsonValue, column: ColumnDefinitio const single = Array.isArray(value) ? value[0] : value return single === undefined ? null : resolveSelectOptionId(single, options) } + +/** + * Normalizes caller-supplied select options to `{ id, name }` pairs. + * + * Cells reference the option id, so an edit that re-sends an option by name + * must reuse the id it already has — minting a fresh one would orphan every + * cell holding it, silently clearing the column. A caller that already supplies + * an id keeps it, which makes this a no-op for the fully-formed options the + * HTTP contracts accept and a repair for the name-only options agents author. + */ +export function normalizeSelectOptionsInput( + raw: unknown, + existing: SelectOption[] = [] +): SelectOption[] | undefined { + if (!Array.isArray(raw)) return undefined + + const idByName = new Map() + for (const option of existing) { + const key = option.name.toLowerCase() + if (!idByName.has(key)) idByName.set(key, option.id) + } + const resolveId = (name: string): string => idByName.get(name.toLowerCase()) ?? generateShortId() + + return raw.map((entry) => { + if (typeof entry === 'string') return { id: resolveId(entry), name: entry } + const e = (entry ?? {}) as { id?: unknown; name?: unknown } + const name = typeof e.name === 'string' ? e.name : String(e.name ?? '') + const id = typeof e.id === 'string' && e.id.length > 0 ? e.id : resolveId(name) + return { id, name } + }) +} diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 5d23ec56931..5a6f78dd84a 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -14,6 +14,7 @@ import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, count, eq, isNull, sql } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' import { resolveRestoredFolderId } from '@/lib/folders/queries' @@ -28,8 +29,6 @@ import type { DbTransaction } from '@/lib/table/planner' import { setTableTxTimeouts } from '@/lib/table/tx' import { type CreateTableData, - TABLE_LOCK_FLAGS, - TABLE_LOCK_KINDS, type TableDefinition, type TableLocks, type TableMetadata, @@ -41,10 +40,15 @@ import { stripGroupDeps } from '@/lib/table/workflow-columns' const logger = createLogger('TableService') -export class TableConflictError extends Error { - readonly code = 'TABLE_EXISTS' as const +/** + * A table name already taken in the workspace. Kept as its own class because + * several routes branch on it specifically, and typed as a `conflict` so the + * generic classifiers reach the same 409 without reading the message. + */ +export class TableConflictError extends OrchestrationError { constructor(name: string) { - super(`A table named "${name}" already exists in this workspace`) + super('conflict', `A table named "${name}" already exists in this workspace`) + this.name = 'TableConflictError' } } @@ -103,7 +107,7 @@ export async function withLockedTable( ) const table = await getTableById(tableId, { tx: trx, includeArchived: opts?.includeArchived }) if (!table) { - throw new Error('Table not found') + throw new OrchestrationError('not_found', 'Table not found') } return mutate(table, trx) }) @@ -285,13 +289,19 @@ export async function createTable( // Validate table name const nameValidation = validateTableName(data.name) if (!nameValidation.valid) { - throw new Error(`Invalid table name: ${nameValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Invalid table name: ${nameValidation.errors.join(', ')}` + ) } // Validate schema const schemaValidation = validateTableSchema(data.schema) if (!schemaValidation.valid) { - throw new Error(`Invalid schema: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Invalid schema: ${schemaValidation.errors.join(', ')}` + ) } const tableId = `tbl_${generateId().replace(/-/g, '')}` @@ -355,7 +365,12 @@ export async function createTable( ) if (Number(existingCount) >= maxTables) { - throw new Error(`Workspace has reached maximum table limit (${maxTables})`) + // A quota ceiling, not bad input — both create routes have always + // answered 403 for it. + throw new OrchestrationError( + 'forbidden', + `Workspace has reached maximum table limit (${maxTables})` + ) } const duplicateName = await trx @@ -474,23 +489,26 @@ export async function addTableColumnsWithTx( for (const column of columns) { if (!NAME_PATTERN.test(column.name)) { - throw new Error( + throw new OrchestrationError( + 'validation', `Invalid column name "${column.name}". Must start with a letter or underscore and contain only alphanumeric characters and underscores.` ) } if (column.name.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) { - throw new Error( + throw new OrchestrationError( + 'validation', `Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)` ) } if (!COLUMN_TYPES.includes(column.type as (typeof COLUMN_TYPES)[number])) { - throw new Error( + throw new OrchestrationError( + 'validation', `Invalid column type "${column.type}". Must be one of: ${COLUMN_TYPES.join(', ')}` ) } const lower = column.name.toLowerCase() if (usedNames.has(lower)) { - throw new Error(`Column "${column.name}" already exists`) + throw new OrchestrationError('validation', `Column "${column.name}" already exists`) } usedNames.add(lower) // Honor a caller-assigned id (the CSV append path pre-assigns so coercion @@ -506,7 +524,8 @@ export async function addTableColumnsWithTx( } if (table.schema.columns.length + additions.length > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { - throw new Error( + throw new OrchestrationError( + 'validation', `Adding ${additions.length} column(s) would exceed maximum column limit (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE})` ) } @@ -572,12 +591,11 @@ export function auditTableColumnsAdded( export async function renameTable( tableId: string, newName: string, - requestId: string, - actingUserId?: string + requestId: string ): Promise<{ id: string; name: string }> { const nameValidation = validateTableName(newName) if (!nameValidation.valid) { - throw new Error(nameValidation.errors.join(', ')) + throw new OrchestrationError('validation', nameValidation.errors.join(', ')) } const now = new Date() @@ -586,29 +604,10 @@ export async function renameTable( .update(userTableDefinitions) .set({ name: newName, updatedAt: now }) .where(eq(userTableDefinitions.id, tableId)) - .returning({ - id: userTableDefinitions.id, - createdBy: userTableDefinitions.createdBy, - workspaceId: userTableDefinitions.workspaceId, - }) + .returning({ id: userTableDefinitions.id }) if (result.length === 0) { - throw new Error(`Table ${tableId} not found`) - } - - const { createdBy, workspaceId } = result[0] - const renameActorId = actingUserId ?? createdBy - if (renameActorId) { - recordAudit({ - workspaceId: workspaceId ?? null, - actorId: renameActorId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: newName, - description: `Renamed table to "${newName}"`, - metadata: { op: 'rename' }, - }) + throw new OrchestrationError('not_found', `Table ${tableId} not found`) } logger.info(`[${requestId}] Renamed table ${tableId} to "${newName}"`) @@ -636,9 +635,8 @@ export async function moveTableToFolder( tableId: string, workspaceId: string, folderId: string | null, - requestId: string, - actingUserId?: string -): Promise { + requestId: string +): Promise<{ name: string }> { const updates: Partial = { folderId, updatedAt: new Date(), @@ -666,27 +664,13 @@ export async function moveTableToFolder( }) if (result.length === 0) { - throw new Error(`Table ${tableId} not found`) + throw new OrchestrationError('not_found', `Table ${tableId} not found`) } - const { name, createdBy } = result[0] - const actorId = actingUserId ?? createdBy - if (actorId) { - recordAudit({ - workspaceId, - actorId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: name, - description: folderId - ? `Moved table "${name}" into a folder` - : `Moved table "${name}" to the workspace root`, - metadata: { op: 'move', folderId }, - }) - } + const { name } = result[0] logger.info(`[${requestId}] Moved table ${tableId} to folder ${folderId ?? 'root'}`) + return { name } } /** @@ -703,11 +687,8 @@ export async function moveTableToFolder( export async function updateTableLocks( tableId: string, partial: Partial, - actingUserId: string, - requestId: string, - /** Forwarded to the audit record for IP / user-agent capture. */ - request?: { headers: { get(name: string): string | null } } -): Promise { + requestId: string +): Promise<{ table: TableDefinition; previousLocks: TableLocks }> { let previousLocks: TableLocks = UNLOCKED_TABLE_LOCKS const updated = await withLockedTable(tableId, async (table, trx) => { previousLocks = table.locks @@ -720,36 +701,12 @@ export async function updateTableLocks( return { ...table, locks: nextLocks, updatedAt: now } }) - // Name the transitions in the description so the audit list is readable - // without expanding metadata — "who locked my production table" is the - // question this feature exists to answer. - const flipped = TABLE_LOCK_KINDS.filter( - (kind) => previousLocks[TABLE_LOCK_FLAGS[kind]] !== updated.locks[TABLE_LOCK_FLAGS[kind]] - ) - const description = flipped.length - ? `Table locks changed: ${flipped - .map((kind) => `${kind} ${updated.locks[TABLE_LOCK_FLAGS[kind]] ? 'locked' : 'unlocked'}`) - .join(', ')}` - : 'Updated table locks (no change)' - - recordAudit({ - workspaceId: updated.workspaceId, - actorId: actingUserId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: updated.name, - description, - metadata: { op: 'update_locks', before: previousLocks, after: updated.locks }, - ...(request ? { request } : {}), - }) - await appendTableEvent({ kind: 'definition', tableId, reason: 'locks' }).catch((error) => { logger.warn(`[${requestId}] Failed to emit lock-change event for table ${tableId}`, { error }) }) logger.info(`[${requestId}] Updated locks for table ${tableId}`) - return updated + return { table: updated, previousLocks } } /** @@ -830,9 +787,8 @@ export async function updateTableMetadata( export async function deleteTable( tableId: string, requestId: string, - actingUserId?: string, options?: { archivedAt?: Date } -): Promise { +): Promise<{ archived: { name: string; workspaceId: string | null } | null }> { const now = options?.archivedAt ?? new Date() // Archiving destroys access to every row, so it is gated on the delete lock. // The guard is inline in the WHERE (atomic — no separate read, no TOCTOU); @@ -874,21 +830,10 @@ export async function deleteTable( } // Otherwise the table is missing or already archived — a silent no-op, as before. } - // Audit only genuine user deletes — rollback callers omit `actingUserId`. The - // caller emits the `table_deleted` PostHog event, so it is not duplicated here. - if (deleted && actingUserId) { - recordAudit({ - workspaceId: deleted.workspaceId ?? null, - actorId: actingUserId, - action: AuditAction.TABLE_DELETED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: deleted.name, - description: `Archived table "${deleted.name}"`, - }) - } - logger.info(`[${requestId}] Archived table ${tableId}`) + // Null when the table was missing or already archived — a silent no-op. The + // caller audits only a genuine archive, so a repeat delete logs nothing. + return { archived: deleted ? { name: deleted.name, workspaceId: deleted.workspaceId } : null } } /** @@ -907,18 +852,18 @@ export async function restoreTable( ): Promise { const table = await getTableById(tableId, { includeArchived: true }) if (!table) { - throw new Error('Table not found') + throw new OrchestrationError('not_found', 'Table not found') } if (!table.archivedAt) { - throw new Error('Table is not archived') + throw new OrchestrationError('validation', 'Table is not archived') } if (table.workspaceId) { const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils') const ws = await getWorkspaceWithOwner(table.workspaceId) if (!ws || ws.archivedAt) { - throw new Error('Cannot restore table into an archived workspace') + throw new OrchestrationError('validation', 'Cannot restore table into an archived workspace') } } diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts index 4054113d4e2..a2ccc24d4d6 100644 --- a/apps/sim/lib/table/workflow-columns.ts +++ b/apps/sim/lib/table/workflow-columns.ts @@ -18,6 +18,7 @@ import { and, eq, inArray, notInArray, sql } from 'drizzle-orm' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import type { EnqueueOptions } from '@/lib/core/async-jobs/types' import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { buildCancelledExecution } from '@/lib/table/cell-write' import type { Filter, @@ -731,8 +732,9 @@ export async function runWorkflowColumn(opts: { // this module; `@trigger.dev/sdk` is heavy and only needed on this op. const { getTableById } = await import('@/lib/table/service') const table = await getTableById(tableId) - if (!table) throw new Error('Table not found') - if (table.workspaceId !== workspaceId) throw new Error('Invalid workspace ID') + if (!table) throw new OrchestrationError('not_found', 'Table not found') + if (table.workspaceId !== workspaceId) + throw new OrchestrationError('validation', 'Invalid workspace ID') const allGroups = table.schema.workflowGroups ?? [] const targetGroups = groupIds ? allGroups.filter((g) => groupIds.includes(g.id)) : allGroups diff --git a/apps/sim/lib/workflows/orchestration/deploy.ts b/apps/sim/lib/workflows/orchestration/deploy.ts index 8a9f49f4a14..69ce7adac44 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.ts @@ -7,6 +7,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { env } from '@/lib/core/config/env' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { getSocketServerUrl } from '@/lib/core/utils/urls' import { captureServerEvent } from '@/lib/posthog/server' @@ -26,7 +27,6 @@ import { notifySocketDeploymentChanged, processWorkflowDeploymentOutboxEvent, } from '@/lib/workflows/deployment-outbox' -import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' import { getWorkflowDeploymentStatus, prepareWorkflowDeployment, diff --git a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts index e4903974aa4..bc1c12e8c73 100644 --- a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts @@ -1,8 +1,8 @@ import type { folder as folderTable } from '@sim/db/schema' import type { FolderResourceType } from '@/lib/api/contracts/folders' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { createFolder, deleteFolder, restoreFolder, updateFolder } from '@/lib/folders/lifecycle' import type { FolderMutationErrorCode } from '@/lib/folders/status' -import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' /** * Workflow-bound entry points into the generic folder engine in `lib/folders/lifecycle.ts`. diff --git a/apps/sim/lib/workflows/orchestration/types.ts b/apps/sim/lib/workflows/orchestration/types.ts deleted file mode 100644 index 70c715ffb2c..00000000000 --- a/apps/sim/lib/workflows/orchestration/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type OrchestrationErrorCode = 'validation' | 'not_found' | 'conflict' | 'internal' - -/** - * Maps an orchestration error code to its HTTP status. Shared by every route - * surface (UI, v1, tool routes) so deployment errors map identically. - */ -export function statusForOrchestrationError(code: OrchestrationErrorCode | undefined): number { - if (code === 'validation') return 400 - if (code === 'not_found') return 404 - if (code === 'conflict') return 409 - return 500 -} diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index 9ccee7d5171..07588af4460 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -6,11 +6,11 @@ import { isFolderInWorkspace } from '@sim/platform-authz/workflow' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull, min, ne } from 'drizzle-orm' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { captureServerEvent } from '@/lib/posthog/server' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { archiveWorkflow, restoreWorkflow } from '@/lib/workflows/lifecycle' -import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { deduplicateWorkflowName } from '@/lib/workflows/utils' From eddd53ac836956877b0ad0757902cd6688ae7f1d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 23:56:16 -0700 Subject: [PATCH 18/24] feat(api): add v2 endpoints for MCP servers, skills, custom tools, folders, and credentials (#6150) * feat(api): add v2 endpoints for MCP servers, skills, custom tools, folders, and credentials * fix(api): correct credential role, skill permission bar, MCP url identity, and custom-tool conflict mapping * fix(api): align credential mutation gating, provider-outage status, and unique-violation conflicts * fix(api): close unique-violation, revival, orphan-write, and env-rename gaps * fix(api): treat every provider-outage code as unavailable on create and update * fix(credentials): use the shared outage predicate on the session update path --- .../content/docs/de/api-reference/meta.json | 5 + .../content/docs/en/api-reference/meta.json | 5 + .../content/docs/es/api-reference/meta.json | 5 + .../content/docs/fr/api-reference/meta.json | 5 + .../content/docs/ja/api-reference/meta.json | 5 + .../content/docs/zh/api-reference/meta.json | 5 + apps/docs/lib/openapi.ts | 1 + apps/docs/openapi-v2-resources.json | 2737 +++++++++++++++++ apps/sim/app/api/credentials/[id]/route.ts | 10 +- apps/sim/app/api/credentials/route.ts | 654 +--- apps/sim/app/api/skills/route.ts | 187 +- apps/sim/app/api/v1/middleware.ts | 10 + .../app/api/v2/credentials/[id]/route.test.ts | 414 +++ apps/sim/app/api/v2/credentials/[id]/route.ts | 216 ++ apps/sim/app/api/v2/credentials/route.test.ts | 339 ++ apps/sim/app/api/v2/credentials/route.ts | 147 + apps/sim/app/api/v2/credentials/utils.ts | 75 + .../api/v2/custom-tools/[id]/route.test.ts | 308 ++ .../sim/app/api/v2/custom-tools/[id]/route.ts | 197 ++ .../sim/app/api/v2/custom-tools/route.test.ts | 267 ++ apps/sim/app/api/v2/custom-tools/route.ts | 136 + apps/sim/app/api/v2/custom-tools/utils.ts | 46 + .../sim/app/api/v2/folders/[id]/route.test.ts | 383 +++ apps/sim/app/api/v2/folders/[id]/route.ts | 209 ++ apps/sim/app/api/v2/folders/route.test.ts | 278 ++ apps/sim/app/api/v2/folders/route.ts | 120 + apps/sim/app/api/v2/folders/utils.ts | 60 + .../app/api/v2/mcp-servers/[id]/route.test.ts | 350 +++ apps/sim/app/api/v2/mcp-servers/[id]/route.ts | 181 ++ apps/sim/app/api/v2/mcp-servers/route.test.ts | 330 ++ apps/sim/app/api/v2/mcp-servers/route.ts | 167 + apps/sim/app/api/v2/mcp-servers/utils.ts | 50 + apps/sim/app/api/v2/skills/[id]/route.test.ts | 331 ++ apps/sim/app/api/v2/skills/[id]/route.ts | 169 + apps/sim/app/api/v2/skills/route.test.ts | 261 ++ apps/sim/app/api/v2/skills/route.ts | 116 + apps/sim/app/api/v2/skills/utils.ts | 48 + apps/sim/lib/api/contracts/skills.ts | 6 +- apps/sim/lib/api/contracts/v2/credentials.ts | 229 ++ apps/sim/lib/api/contracts/v2/custom-tools.ts | 148 + apps/sim/lib/api/contracts/v2/folders.ts | 178 ++ apps/sim/lib/api/contracts/v2/mcp-servers.ts | 217 ++ apps/sim/lib/api/contracts/v2/skills.ts | 156 + .../tools/handlers/management/manage-skill.ts | 150 +- .../orchestration/credential-create.ts | 588 ++++ .../lib/credentials/orchestration/index.ts | 8 + apps/sim/lib/credentials/queries.ts | 114 + apps/sim/lib/folders/queries.ts | 28 + apps/sim/lib/mcp/queries.ts | 63 + apps/sim/lib/posthog/events.ts | 6 +- apps/sim/lib/skills/orchestration/index.ts | 12 + .../skills/orchestration/skill-lifecycle.ts | 359 +++ .../lib/workflows/custom-tools/operations.ts | 80 + scripts/check-api-validation-contracts.ts | 4 +- scripts/check-openapi-specs.ts | 1 + 55 files changed, 10340 insertions(+), 834 deletions(-) create mode 100644 apps/docs/openapi-v2-resources.json create mode 100644 apps/sim/app/api/v2/credentials/[id]/route.test.ts create mode 100644 apps/sim/app/api/v2/credentials/[id]/route.ts create mode 100644 apps/sim/app/api/v2/credentials/route.test.ts create mode 100644 apps/sim/app/api/v2/credentials/route.ts create mode 100644 apps/sim/app/api/v2/credentials/utils.ts create mode 100644 apps/sim/app/api/v2/custom-tools/[id]/route.test.ts create mode 100644 apps/sim/app/api/v2/custom-tools/[id]/route.ts create mode 100644 apps/sim/app/api/v2/custom-tools/route.test.ts create mode 100644 apps/sim/app/api/v2/custom-tools/route.ts create mode 100644 apps/sim/app/api/v2/custom-tools/utils.ts create mode 100644 apps/sim/app/api/v2/folders/[id]/route.test.ts create mode 100644 apps/sim/app/api/v2/folders/[id]/route.ts create mode 100644 apps/sim/app/api/v2/folders/route.test.ts create mode 100644 apps/sim/app/api/v2/folders/route.ts create mode 100644 apps/sim/app/api/v2/folders/utils.ts create mode 100644 apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts create mode 100644 apps/sim/app/api/v2/mcp-servers/[id]/route.ts create mode 100644 apps/sim/app/api/v2/mcp-servers/route.test.ts create mode 100644 apps/sim/app/api/v2/mcp-servers/route.ts create mode 100644 apps/sim/app/api/v2/mcp-servers/utils.ts create mode 100644 apps/sim/app/api/v2/skills/[id]/route.test.ts create mode 100644 apps/sim/app/api/v2/skills/[id]/route.ts create mode 100644 apps/sim/app/api/v2/skills/route.test.ts create mode 100644 apps/sim/app/api/v2/skills/route.ts create mode 100644 apps/sim/app/api/v2/skills/utils.ts create mode 100644 apps/sim/lib/api/contracts/v2/credentials.ts create mode 100644 apps/sim/lib/api/contracts/v2/custom-tools.ts create mode 100644 apps/sim/lib/api/contracts/v2/folders.ts create mode 100644 apps/sim/lib/api/contracts/v2/mcp-servers.ts create mode 100644 apps/sim/lib/api/contracts/v2/skills.ts create mode 100644 apps/sim/lib/credentials/orchestration/credential-create.ts create mode 100644 apps/sim/lib/credentials/queries.ts create mode 100644 apps/sim/lib/mcp/queries.ts create mode 100644 apps/sim/lib/skills/orchestration/index.ts create mode 100644 apps/sim/lib/skills/orchestration/skill-lifecycle.ts diff --git a/apps/docs/content/docs/de/api-reference/meta.json b/apps/docs/content/docs/de/api-reference/meta.json index 74cedc72725..d2e994fef8e 100644 --- a/apps/docs/content/docs/de/api-reference/meta.json +++ b/apps/docs/content/docs/de/api-reference/meta.json @@ -15,6 +15,11 @@ "(generated)/tables", "(generated)/files", "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/folders", + "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", "(generated)/human-in-the-loop", diff --git a/apps/docs/content/docs/en/api-reference/meta.json b/apps/docs/content/docs/en/api-reference/meta.json index 74cedc72725..d2e994fef8e 100644 --- a/apps/docs/content/docs/en/api-reference/meta.json +++ b/apps/docs/content/docs/en/api-reference/meta.json @@ -15,6 +15,11 @@ "(generated)/tables", "(generated)/files", "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/folders", + "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", "(generated)/human-in-the-loop", diff --git a/apps/docs/content/docs/es/api-reference/meta.json b/apps/docs/content/docs/es/api-reference/meta.json index 74cedc72725..d2e994fef8e 100644 --- a/apps/docs/content/docs/es/api-reference/meta.json +++ b/apps/docs/content/docs/es/api-reference/meta.json @@ -15,6 +15,11 @@ "(generated)/tables", "(generated)/files", "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/folders", + "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", "(generated)/human-in-the-loop", diff --git a/apps/docs/content/docs/fr/api-reference/meta.json b/apps/docs/content/docs/fr/api-reference/meta.json index 74cedc72725..d2e994fef8e 100644 --- a/apps/docs/content/docs/fr/api-reference/meta.json +++ b/apps/docs/content/docs/fr/api-reference/meta.json @@ -15,6 +15,11 @@ "(generated)/tables", "(generated)/files", "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/folders", + "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", "(generated)/human-in-the-loop", diff --git a/apps/docs/content/docs/ja/api-reference/meta.json b/apps/docs/content/docs/ja/api-reference/meta.json index 74cedc72725..d2e994fef8e 100644 --- a/apps/docs/content/docs/ja/api-reference/meta.json +++ b/apps/docs/content/docs/ja/api-reference/meta.json @@ -15,6 +15,11 @@ "(generated)/tables", "(generated)/files", "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/folders", + "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", "(generated)/human-in-the-loop", diff --git a/apps/docs/content/docs/zh/api-reference/meta.json b/apps/docs/content/docs/zh/api-reference/meta.json index 74cedc72725..d2e994fef8e 100644 --- a/apps/docs/content/docs/zh/api-reference/meta.json +++ b/apps/docs/content/docs/zh/api-reference/meta.json @@ -15,6 +15,11 @@ "(generated)/tables", "(generated)/files", "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/folders", + "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", "(generated)/human-in-the-loop", diff --git a/apps/docs/lib/openapi.ts b/apps/docs/lib/openapi.ts index 41f0687139a..c3dac25a837 100644 --- a/apps/docs/lib/openapi.ts +++ b/apps/docs/lib/openapi.ts @@ -9,6 +9,7 @@ const SPEC_FILES = [ 'openapi-v2-tables.json', 'openapi-v2-knowledge.json', 'openapi-v2-files-audit.json', + 'openapi-v2-resources.json', ] as const export const openapi = createOpenAPI({ diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json new file mode 100644 index 00000000000..d2683950f38 --- /dev/null +++ b/apps/docs/openapi-v2-resources.json @@ -0,0 +1,2737 @@ +{ + "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": "Folders", + "description": "Organize workflows, knowledge bases, and tables into folder trees (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" }], + "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" }], + "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" }], + "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/folders": { + "get": { + "operationId": "listFolders", + "summary": "List Folders", + "description": "List a workspace's folder tree for one resource type. One folder engine serves several trees, so `resourceType` is **required** — it selects which tree you are addressing.\n\nPass `scope=archived` to list folders in Recently Deleted instead of live ones. A workspace's tree for one resource type is small and bounded, so the full set is returned as a single page and `nextCursor` is always `null`. Folders come back in tree order (`sortOrder`, then creation time); build the hierarchy from `parentId`.", + "tags": ["Folders"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/folders?workspaceId=YOUR_WORKSPACE_ID&resourceType=workflow\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/FolderResourceTypeQuery" }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "`active` (default) lists live folders; `archived` lists Recently Deleted.", + "schema": { "type": "string", "enum": ["active", "archived"], "default": "active" } + } + ], + "responses": { + "200": { + "description": "Folders in the workspace's tree for the requested resource type.", + "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 folders in the requested tree.", + "items": { "$ref": "#/components/schemas/Folder" } + }, + "nextCursor": { "$ref": "#/components/schemas/NextCursor" } + } + }, + "example": { + "data": [ + { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "resourceType": "workflow", + "name": "Onboarding", + "parentId": null, + "locked": false, + "sortOrder": 0, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z", + "deletedAt": 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" } + } + }, + "post": { + "operationId": "createFolder", + "summary": "Create Folder", + "description": "Create a folder in one of a workspace's resource trees. Requires `write` permission on the workspace.\n\n`resourceType` is required and selects the tree. Pass `parentId: null` (or omit it) to create the folder at the root; a `parentId` must name a live folder of the same `resourceType` in the same workspace.\n\nA sibling folder with the same name returns `409 CONFLICT`. If the parent is a locked workflow folder, the request returns `423 LOCKED`.", + "tags": ["Folders"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/folders\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"resourceType\": \"workflow\",\n \"name\": \"Onboarding\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The folder to create.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateFolderBody" }, + "examples": { + "rootFolder": { + "summary": "A workflow folder at the workspace root", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "resourceType": "workflow", + "name": "Onboarding" + } + }, + "nestedKnowledgeFolder": { + "summary": "A knowledge-base folder nested under another", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "resourceType": "knowledge_base", + "name": "Policies", + "parentId": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The folder 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/FolderData" }, + "example": { + "data": { + "folder": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "resourceType": "workflow", + "name": "Onboarding", + "parentId": null, + "locked": false, + "sortOrder": 0, + "createdAt": "2025-06-20T14:02:11.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z", + "deletedAt": null + } + } + } + } + } + }, + "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/folders/{id}": { + "get": { + "operationId": "getFolder", + "summary": "Get Folder", + "description": "Fetch a single folder by id. Archived folders resolve too — check `deletedAt` to tell them apart from live ones.", + "tags": ["Folders"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/folders/7c9e6679-7425-40de-944b-e07fc1f90ae7?workspaceId=YOUR_WORKSPACE_ID&resourceType=workflow\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/FolderId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/FolderResourceTypeQuery" } + ], + "responses": { + "200": { + "description": "The 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": { "$ref": "#/components/schemas/FolderData" }, + "example": { + "data": { + "folder": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "resourceType": "workflow", + "name": "Onboarding", + "parentId": null, + "locked": false, + "sortOrder": 0, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z", + "deletedAt": 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" } + } + }, + "patch": { + "operationId": "updateFolder", + "summary": "Update Folder", + "description": "Rename, move, or reorder a folder. Only the fields you send are changed.\n\nMoving is `parentId` — pass `null` to move to the root. A move that would place a folder inside its own subtree is rejected.\n\n`locked` applies to workflow folders only and requires workspace `admin`; sending it for another tree returns `400`. Everything else needs workspace `write`.\n\nArchived folders cannot be updated (`404`), and a mutation lock anywhere on the path returns `423 LOCKED`.", + "tags": ["Folders"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/folders/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 \"resourceType\": \"workflow\",\n \"name\": \"Customer Onboarding\"\n }'" + } + ], + "parameters": [{ "$ref": "#/components/parameters/FolderId" }], + "requestBody": { + "required": true, + "description": "The fields to change. At least one of `name`, `locked`, `parentId`, or `sortOrder` is required.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateFolderBody" }, + "examples": { + "rename": { + "summary": "Rename a folder", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "resourceType": "workflow", + "name": "Customer Onboarding" + } + }, + "moveToRoot": { + "summary": "Move a folder to the workspace root", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "resourceType": "workflow", + "parentId": null + } + }, + "lock": { + "summary": "Lock a workflow folder (requires admin)", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "resourceType": "workflow", + "locked": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated 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": { "$ref": "#/components/schemas/FolderData" }, + "example": { + "data": { + "folder": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "resourceType": "workflow", + "name": "Customer Onboarding", + "parentId": null, + "locked": false, + "sortOrder": 0, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-21T08:30:00.000Z", + "deletedAt": 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" } + } + }, + "delete": { + "operationId": "deleteFolder", + "summary": "Delete Folder", + "description": "Archive a folder and everything under it. The cascade moves the subtree — subfolders and the resources filed in them — into Recently Deleted, and `deletedItems` reports how much was archived; only the count matching `resourceType` is populated.\n\nDeleting is idempotent: re-issuing it against an already archived folder retries the cascade onto the same snapshot rather than 404ing, so a run that failed partway can be completed.\n\nA mutation lock anywhere in the subtree returns `423 LOCKED`.", + "tags": ["Folders"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/folders/7c9e6679-7425-40de-944b-e07fc1f90ae7?workspaceId=YOUR_WORKSPACE_ID&resourceType=workflow\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/FolderId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/FolderResourceTypeQuery" } + ], + "responses": { + "200": { + "description": "The folder and its subtree were archived.", + "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/FolderDeleteAcknowledgement" }, + "example": { + "data": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "deleted": true, + "deletedItems": { "folders": 3, "workflows": 12 } + } + } + } + } + }, + "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/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" } + } + ], + "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 \u2014 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 \u2014 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" + } + }, + "FolderResourceTypeQuery": { + "name": "resourceType", + "in": "query", + "required": true, + "description": "Which resource tree the folder belongs to. Required — folder ids are unique, but addressing the wrong tree would file a folder where its page can never see it.", + "schema": { + "type": "string", + "enum": ["workflow", "knowledge_base", "table"], + "example": "workflow" + } + } + }, + "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 } + } + }, + "Folder": { + "type": "object", + "description": "A folder in one of a workspace's resource trees.", + "required": [ + "id", + "resourceType", + "name", + "parentId", + "locked", + "sortOrder", + "createdAt", + "updatedAt", + "deletedAt" + ], + "properties": { + "id": { "type": "string", "description": "The folder's unique identifier." }, + "resourceType": { + "type": "string", + "enum": ["workflow", "file", "knowledge_base", "table"], + "description": "Which resource tree the folder belongs to. Only `workflow`, `knowledge_base`, and `table` are served by this API; `file` folders have their own surface." + }, + "name": { "type": "string", "description": "Display name." }, + "parentId": { + "type": ["string", "null"], + "description": "The containing folder, or null when the folder sits at the workspace root." + }, + "locked": { + "type": "boolean", + "description": "Whether the folder is locked against modification. Workflow folders only; always false elsewhere." + }, + "sortOrder": { + "type": "number", + "description": "Position among its siblings, ascending." + }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "deletedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "When the folder was archived into Recently Deleted, or null when it is live." + } + } + }, + "FolderData": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { "folder": { "$ref": "#/components/schemas/Folder" } } + } + } + }, + "FolderDeleteAcknowledgement": { + "type": "object", + "description": "Acknowledgement that a folder was archived, with what the cascade took with it.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id", "deleted"], + "properties": { + "id": { + "type": "string", + "description": "The identifier of the folder that was archived." + }, + "deleted": { "type": "boolean", "const": true }, + "deletedItems": { + "type": "object", + "description": "How much the cascade archived. Only the count matching the folder's `resourceType` is populated.", + "required": ["folders"], + "properties": { + "folders": { + "type": "integer", + "description": "Subfolders archived, including the folder itself." + }, + "workflows": { "type": "integer" }, + "files": { "type": "integer" }, + "knowledgeBases": { "type": "integer" }, + "tables": { "type": "integer" } + } + } + } + } + } + }, + "CreateFolderBody": { + "type": "object", + "description": "A new folder.", + "additionalProperties": false, + "required": ["workspaceId", "resourceType", "name"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace to create the folder in." + }, + "resourceType": { + "type": "string", + "enum": ["workflow", "knowledge_base", "table"], + "description": "Which resource tree to create the folder in. Required." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Display name. Must be unique among its siblings." + }, + "parentId": { + "type": ["string", "null"], + "minLength": 1, + "description": "The containing folder. Omit or pass null to create at the workspace root." + }, + "sortOrder": { + "type": "integer", + "minimum": 0, + "description": "Position among its siblings. Defaults to the top of the list." + } + } + }, + "UpdateFolderBody": { + "type": "object", + "description": "Fields to change on an existing folder. At least one of `name`, `locked`, `parentId`, or `sortOrder` is required; omitted fields keep their stored values.", + "additionalProperties": false, + "required": ["workspaceId", "resourceType"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the folder." + }, + "resourceType": { + "type": "string", + "enum": ["workflow", "knowledge_base", "table"], + "description": "Which resource tree the folder belongs to. Required." + }, + "name": { "type": "string", "minLength": 1, "maxLength": 255 }, + "locked": { + "type": "boolean", + "description": "Workflow folders only, and changing it requires workspace `admin`." + }, + "parentId": { + "type": ["string", "null"], + "minLength": 1, + "description": "New parent folder. Pass null to move to the workspace root." + }, + "sortOrder": { "type": "integer", "minimum": 0 } + } + }, + "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 } + } + }, + "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 } + } + } + } + } +} diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index c0d72341ed5..1f63035ed85 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') @@ -101,7 +105,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 99ba00a6151..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,56 +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: - 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) { @@ -288,433 +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, - } = 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, - }) - 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: { - credentialType: type, - providerId: resolvedProviderId, - ...extraAuditMetadata, - }, - 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/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/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 1eb3d86acfe..97f7372f7a6 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -45,6 +45,16 @@ export type ApiEndpoint = | 'knowledge-search' | 'copilot-chat' | 'billing-usage' + | 'mcp-servers' + | 'mcp-server-detail' + | 'skills' + | 'skill-detail' + | 'custom-tools' + | 'custom-tool-detail' + | 'folders' + | 'folder-detail' + | 'credentials' + | 'credential-detail' export interface RateLimitResult { allowed: boolean 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..d9fa30535a9 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -0,0 +1,339 @@ +/** + * @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' }) + ) + }) +}) + +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', + }) + ) + }) +}) 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..232110187c1 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/route.ts @@ -0,0 +1,147 @@ +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 } = 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, + }) + + // 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..5693e018448 --- /dev/null +++ b/apps/sim/app/api/v2/custom-tools/route.test.ts @@ -0,0 +1,267 @@ +/** + * @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, + } +} + +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' }) + }) +}) + +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..b746b285cc2 --- /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 } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const rows = await listWorkspaceCustomTools({ workspaceId }) + + // 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/folders/[id]/route.test.ts b/apps/sim/app/api/v2/folders/[id]/route.test.ts new file mode 100644 index 00000000000..6ab9c1d3957 --- /dev/null +++ b/apps/sim/app/api/v2/folders/[id]/route.test.ts @@ -0,0 +1,383 @@ +/** + * @vitest-environment node + * + * Public v2 folder detail: the archived-row split between PATCH and DELETE, the + * admin gate on `locked`, and the 423 a mutation lock produces. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockFindActiveFolder, + mockFindFolderInWorkspace, + mockUpdateFolder, + mockDeleteFolder, + mockAssertFolderMutable, + FolderLockedErrorMock, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockFindActiveFolder: vi.fn(), + mockFindFolderInWorkspace: vi.fn(), + mockUpdateFolder: vi.fn(), + mockDeleteFolder: vi.fn(), + mockAssertFolderMutable: vi.fn(), + FolderLockedErrorMock: class FolderLockedError extends Error { + status = 423 + }, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/folders/queries', () => ({ + findActiveFolder: mockFindActiveFolder, + findFolderInWorkspace: mockFindFolderInWorkspace, +})) + +vi.mock('@/lib/folders/lifecycle', () => ({ + updateFolder: mockUpdateFolder, + deleteFolder: mockDeleteFolder, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + assertFolderMutable: mockAssertFolderMutable, + FolderLockedError: FolderLockedErrorMock, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { DELETE, GET, PATCH } from '@/app/api/v2/folders/[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: Record = {}) { + return { + id: 'fld_abc123', + resourceType: 'workflow', + name: 'Onboarding', + userId: 'user-1', + workspaceId: 'workspace-1', + parentId: null, + locked: false, + sortOrder: 0, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + deletedAt: null, + ...overrides, + } +} + +const routeContext = () => ({ params: Promise.resolve({ id: 'fld_abc123' }) }) +const url = (query = 'workspaceId=workspace-1&resourceType=workflow') => + `http://localhost:3000/api/v2/folders/fld_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/folders/fld_abc123', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + routeContext() + ) +} + +describe('GET /api/v2/folders/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockFindFolderInWorkspace.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(mockFindFolderInWorkspace).not.toHaveBeenCalled() + }) + + it('400s when resourceType is missing', async () => { + const res = await callGet('workspaceId=workspace-1') + expect(res.status).toBe(400) + expect(mockFindFolderInWorkspace).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(mockFindFolderInWorkspace).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 folder is not in this workspace tree', async () => { + mockFindFolderInWorkspace.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('returns the public folder shape', async () => { + const res = await callGet() + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.folder).toEqual({ + id: 'fld_abc123', + resourceType: 'workflow', + name: 'Onboarding', + parentId: null, + locked: false, + sortOrder: 0, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + deletedAt: null, + }) + expect(mockFindFolderInWorkspace).toHaveBeenCalledWith('fld_abc123', 'workspace-1', 'workflow') + }) +}) + +describe('PATCH /api/v2/folders/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockFindActiveFolder.mockResolvedValue(buildRow()) + mockAssertFolderMutable.mockResolvedValue(undefined) + mockUpdateFolder.mockResolvedValue({ success: true, folder: buildRow({ name: 'Renamed' }) }) + }) + + 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', + resourceType: 'workflow', + name: 'Renamed', + }) + + expect(res.status).toBe(404) + expect(mockUpdateFolder).not.toHaveBeenCalled() + }) + + it('400s when no field to change is supplied', async () => { + const res = await callPatch({ workspaceId: 'workspace-1', resourceType: 'workflow' }) + expect(res.status).toBe(400) + expect(mockUpdateFolder).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', + resourceType: 'workflow', + name: 'Renamed', + }) + expect(res.status).toBe(403) + expect(mockUpdateFolder).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPatch({ + workspaceId: 'workspace-1', + resourceType: 'workflow', + name: 'Renamed', + }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('requires only write permission for an ordinary rename', async () => { + await callPatch({ workspaceId: 'workspace-1', resourceType: 'workflow', name: 'Renamed' }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + 'workspace-1', + 'write' + ) + }) + + it('escalates to admin when locked is being set', async () => { + await callPatch({ workspaceId: 'workspace-1', resourceType: 'workflow', locked: true }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + 'workspace-1', + 'admin' + ) + }) + + it('400s when locked is sent for a tree that does not support locking', async () => { + const res = await callPatch({ + workspaceId: 'workspace-1', + resourceType: 'table', + locked: true, + }) + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('workflow folders') + expect(mockUpdateFolder).not.toHaveBeenCalled() + }) + + it('404s on an archived folder so a locked subtree cannot be edited through it', async () => { + mockFindActiveFolder.mockResolvedValue(null) + const res = await callPatch({ + workspaceId: 'workspace-1', + resourceType: 'workflow', + name: 'Renamed', + }) + expect(res.status).toBe(404) + expect(mockUpdateFolder).not.toHaveBeenCalled() + }) + + it('423s when a mutation lock blocks the change', async () => { + mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked')) + const res = await callPatch({ + workspaceId: 'workspace-1', + resourceType: 'workflow', + name: 'Renamed', + }) + expect(res.status).toBe(423) + expect((await res.json()).error.code).toBe('LOCKED') + expect(mockUpdateFolder).not.toHaveBeenCalled() + }) + + it('updates the folder and returns the public shape', async () => { + const res = await callPatch({ + workspaceId: 'workspace-1', + resourceType: 'workflow', + name: 'Renamed', + }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.folder.name).toBe('Renamed') + expect(mockUpdateFolder).toHaveBeenCalledWith( + expect.objectContaining({ + resourceType: 'workflow', + folderId: 'fld_abc123', + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Renamed', + }) + ) + }) +}) + +describe('DELETE /api/v2/folders/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockFindFolderInWorkspace.mockResolvedValue(buildRow()) + mockAssertFolderMutable.mockResolvedValue(undefined) + mockDeleteFolder.mockResolvedValue({ + success: true, + deletedItems: { folders: 2, workflows: 5 }, + }) + }) + + 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(mockDeleteFolder).not.toHaveBeenCalled() + }) + + it('400s when resourceType is missing', async () => { + const res = await callDelete('workspaceId=workspace-1') + expect(res.status).toBe(400) + expect(mockDeleteFolder).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(mockDeleteFolder).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 folder is not in this workspace tree', async () => { + mockFindFolderInWorkspace.mockResolvedValue(null) + const res = await callDelete() + expect(res.status).toBe(404) + expect(mockDeleteFolder).not.toHaveBeenCalled() + }) + + it('423s when a mutation lock blocks the delete', async () => { + mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked')) + const res = await callDelete() + expect(res.status).toBe(423) + expect((await res.json()).error.code).toBe('LOCKED') + expect(mockDeleteFolder).not.toHaveBeenCalled() + }) + + it('deletes the folder and reports the cascade counts', async () => { + const res = await callDelete() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + data: { id: 'fld_abc123', deleted: true, deletedItems: { folders: 2, workflows: 5 } }, + }) + expect(mockDeleteFolder).toHaveBeenCalledWith( + expect.objectContaining({ + resourceType: 'workflow', + folderId: 'fld_abc123', + workspaceId: 'workspace-1', + userId: 'user-1', + folderName: 'Onboarding', + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/folders/[id]/route.ts b/apps/sim/app/api/v2/folders/[id]/route.ts new file mode 100644 index 00000000000..b05d090d600 --- /dev/null +++ b/apps/sim/app/api/v2/folders/[id]/route.ts @@ -0,0 +1,209 @@ +import { createLogger } from '@sim/logger' +import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteFolderContract, + v2GetFolderContract, + v2UpdateFolderContract, +} from '@/lib/api/contracts/v2/folders' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { folderResourceConfig } from '@/lib/folders/config' +import { deleteFolder, updateFolder } from '@/lib/folders/lifecycle' +import { findActiveFolder, findFolderInWorkspace } from '@/lib/folders/queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2Folder, v2FolderMutationError } from '@/app/api/v2/folders/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FolderDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RouteContext { + params: Promise<{ id: string }> +} + +/** GET /api/v2/folders/[id] — Fetch a single folder, archived or live. */ +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'folder-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetFolderContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, resourceType } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const folder = await findFolderInWorkspace(id, workspaceId, resourceType) + if (!folder) return v2Error('NOT_FOUND', 'Folder not found') + + return v2Data({ folder: toV2Folder(folder) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error fetching folder`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/folders/[id] — Rename, move, reorder, or lock a folder. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'folder-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateFolderContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, resourceType, name, locked, parentId, sortOrder } = parsed.data.body + + /** + * Setting `locked` is an admin capability, matching the UI; every other + * field needs only workspace write. + */ + const access = await resolveWorkspaceAccess( + rateLimit, + userId, + workspaceId, + locked === undefined ? 'write' : 'admin' + ) + if (access) return v2WorkspaceAccessError(access) + + /** + * Archived folders are excluded deliberately: `getFolderLockStatus` skips + * archived rows, so an archived-but-locked folder reports unlocked. Without + * this filter, deleting a folder would make every locked subfolder under it + * freely renameable and reparentable. + */ + const existing = await findActiveFolder(id, workspaceId, resourceType) + if (!existing) return v2Error('NOT_FOUND', 'Folder not found') + + const supportsLocking = Boolean(folderResourceConfig(resourceType).supportsLocking) + if (locked !== undefined && !supportsLocking) { + return v2Error('BAD_REQUEST', 'Folder locking is only supported for workflow folders') + } + + if (supportsLocking) { + const hasNonLockUpdate = + name !== undefined || parentId !== undefined || sortOrder !== undefined + if (hasNonLockUpdate) await assertFolderMutable(id) + if (parentId !== undefined) await assertFolderMutable(parentId) + } + + const result = await updateFolder({ + resourceType, + folderId: id, + workspaceId, + userId, + name, + locked, + parentId, + sortOrder, + }) + + if (!result.success || !result.folder) { + return v2FolderMutationError(result.errorCode, result.error ?? 'Failed to update folder') + } + + return v2Data({ folder: toV2Folder(result.folder) }, { rateLimit }) + } catch (error) { + if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) + + logger.error(`[${requestId}] Error updating folder`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/folders/[id] — Archive a folder and cascade to its contents. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'folder-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteFolderContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, resourceType } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + /** + * Archived rows are included on purpose: `deleteFolder` reuses an already + * archived folder's own `deletedAt` so a cascade that failed partway can be + * retried onto the same snapshot. 404ing here would strand those. + */ + const existing = await findFolderInWorkspace(id, workspaceId, resourceType) + if (!existing) return v2Error('NOT_FOUND', 'Folder not found') + + if (folderResourceConfig(resourceType).supportsLocking) { + await assertFolderMutable(id) + } + + const result = await deleteFolder({ + resourceType, + folderId: id, + workspaceId, + userId, + folderName: existing.name, + }) + + if (!result.success) { + return v2FolderMutationError(result.errorCode, result.error ?? 'Failed to delete folder') + } + + return v2Data({ id, deleted: true as const, deletedItems: result.deletedItems }, { rateLimit }) + } catch (error) { + if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) + + logger.error(`[${requestId}] Error deleting folder`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/folders/route.test.ts b/apps/sim/app/api/v2/folders/route.test.ts new file mode 100644 index 00000000000..85399484b5b --- /dev/null +++ b/apps/sim/app/api/v2/folders/route.test.ts @@ -0,0 +1,278 @@ +/** + * @vitest-environment node + * + * Public v2 folders list/create: gate ordering, the required-`resourceType` + * departure from the internal default, and the lock check on create. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockListFoldersForWorkspace, + mockCreateFolder, + mockAssertFolderMutable, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockListFoldersForWorkspace: vi.fn(), + mockCreateFolder: vi.fn(), + mockAssertFolderMutable: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/folders/queries', () => ({ + listFoldersForWorkspace: mockListFoldersForWorkspace, +})) + +vi.mock('@/lib/folders/lifecycle', () => ({ + createFolder: mockCreateFolder, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + assertFolderMutable: mockAssertFolderMutable, + FolderLockedError: class FolderLockedError extends Error { + status = 423 + }, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET, POST } from '@/app/api/v2/folders/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 FOLDER_API = { + id: 'fld_abc123', + resourceType: 'workflow' as const, + name: 'Onboarding', + userId: 'user-1', + workspaceId: 'workspace-1', + parentId: null, + locked: false, + sortOrder: 0, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + deletedAt: null, +} + +function buildRow(overrides: Record = {}) { + return { + id: 'fld_abc123', + resourceType: 'workflow', + name: 'Onboarding', + userId: 'user-1', + workspaceId: 'workspace-1', + parentId: null, + locked: false, + sortOrder: 0, + 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/folders?${query}`)) + +function callCreate(body: unknown) { + return POST( + new NextRequest('http://localhost:3000/api/v2/folders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + +const VALID_BODY = { + workspaceId: 'workspace-1', + resourceType: 'workflow', + name: 'Onboarding', +} + +describe('GET /api/v2/folders', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockListFoldersForWorkspace.mockResolvedValue([FOLDER_API]) + }) + + 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&resourceType=workflow') + + expect(res.status).toBe(404) + expect(mockListFoldersForWorkspace).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callList('resourceType=workflow') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockListFoldersForWorkspace).not.toHaveBeenCalled() + }) + + it('400s when resourceType is omitted instead of defaulting to workflow', async () => { + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(400) + expect(mockListFoldersForWorkspace).not.toHaveBeenCalled() + }) + + it('400s on a resourceType outside the served set', async () => { + const res = await callList('workspaceId=workspace-1&resourceType=file') + expect(res.status).toBe(400) + expect(mockListFoldersForWorkspace).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&resourceType=workflow') + expect(res.status).toBe(403) + expect(mockListFoldersForWorkspace).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callList('workspaceId=workspace-1&resourceType=workflow') + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('returns the public folder shape without internal scoping columns', async () => { + const res = await callList('workspaceId=workspace-1&resourceType=workflow') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + id: 'fld_abc123', + resourceType: 'workflow', + name: 'Onboarding', + parentId: null, + locked: false, + sortOrder: 0, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + deletedAt: null, + }, + ]) + expect(mockListFoldersForWorkspace).toHaveBeenCalledWith('workspace-1', 'active', 'workflow') + }) + + it('passes the archived scope through', async () => { + await callList('workspaceId=workspace-1&resourceType=table&scope=archived') + expect(mockListFoldersForWorkspace).toHaveBeenCalledWith('workspace-1', 'archived', 'table') + }) +}) + +describe('POST /api/v2/folders', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockAssertFolderMutable.mockResolvedValue(undefined) + mockCreateFolder.mockResolvedValue({ success: true, folder: 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(mockCreateFolder).not.toHaveBeenCalled() + }) + + it('400s when the name is empty', async () => { + const res = await callCreate({ ...VALID_BODY, name: ' ' }) + expect(res.status).toBe(400) + expect(mockCreateFolder).not.toHaveBeenCalled() + }) + + it('400s when resourceType is omitted', async () => { + const res = await callCreate({ workspaceId: 'workspace-1', name: 'Onboarding' }) + expect(res.status).toBe(400) + expect(mockCreateFolder).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(mockCreateFolder).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 when a sibling folder already has the name', async () => { + mockCreateFolder.mockResolvedValue({ + success: false, + error: 'A folder with this name already exists in this location', + errorCode: 'conflict', + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('creates the folder and returns 201', async () => { + const res = await callCreate({ ...VALID_BODY, parentId: null }) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data.folder).toMatchObject({ id: 'fld_abc123', name: 'Onboarding' }) + expect(body.data.folder.userId).toBeUndefined() + expect(mockCreateFolder).toHaveBeenCalledWith( + expect.objectContaining({ + resourceType: 'workflow', + userId: 'user-1', + workspaceId: 'workspace-1', + name: 'Onboarding', + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/folders/route.ts b/apps/sim/app/api/v2/folders/route.ts new file mode 100644 index 00000000000..2ce758499b4 --- /dev/null +++ b/apps/sim/app/api/v2/folders/route.ts @@ -0,0 +1,120 @@ +import { createLogger } from '@sim/logger' +import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateFolderContract, v2ListFoldersContract } from '@/lib/api/contracts/v2/folders' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { folderResourceConfig } from '@/lib/folders/config' +import { createFolder } from '@/lib/folders/lifecycle' +import { listFoldersForWorkspace } from '@/lib/folders/queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2Folder, toV2FolderFromApi, v2FolderMutationError } from '@/app/api/v2/folders/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('V2FoldersAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/folders — List a workspace's folder tree for one resource type. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'folders') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListFoldersContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, resourceType, scope } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const folders = await listFoldersForWorkspace(workspaceId, scope, resourceType) + + // One workspace's tree for one resource type is bounded → a single full page. + return v2CursorList(folders.map(toV2FolderFromApi), null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing folders`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/folders — Create a folder in one of a workspace's resource trees. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'folders') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateFolderContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, resourceType, name, parentId, sortOrder } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + // Locking is a workflow-only feature; other trees leave `locked` false. + if (folderResourceConfig(resourceType).supportsLocking) { + await assertFolderMutable(parentId ?? null) + } + + const result = await createFolder({ + resourceType, + userId, + workspaceId, + name, + parentId, + sortOrder, + }) + + if (!result.success || !result.folder) { + return v2FolderMutationError(result.errorCode, result.error ?? 'Failed to create folder') + } + + return v2Data({ folder: toV2Folder(result.folder) }, { rateLimit, status: 201 }) + } catch (error) { + if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) + + logger.error(`[${requestId}] Error creating folder`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/folders/utils.ts b/apps/sim/app/api/v2/folders/utils.ts new file mode 100644 index 00000000000..c041e880013 --- /dev/null +++ b/apps/sim/app/api/v2/folders/utils.ts @@ -0,0 +1,60 @@ +import type { folder as folderTable } from '@sim/db/schema' +import { omit } from '@sim/utils/object' +import type { NextResponse } from 'next/server' +import type { FolderApi } from '@/lib/api/contracts/folders' +import type { V2Folder } from '@/lib/api/contracts/v2/folders' +import type { FolderMutationErrorCode } from '@/lib/folders/status' +import { v2Error } from '@/app/api/v2/lib/response' + +/** Shared serialization + error mapping for the v2 folders surface. */ + +type FolderRow = typeof folderTable.$inferSelect + +/** + * Narrows an already-serialized {@link FolderApi} (what the shared list query + * returns) to the public projection. + */ +export function toV2FolderFromApi(row: FolderApi): V2Folder { + return omit(row, ['userId', 'workspaceId']) +} + +/** + * Public folder projection. `userId` and `workspaceId` are internal scoping + * columns and are not exposed. + */ +export function toV2Folder(row: FolderRow): V2Folder { + return { + id: row.id, + resourceType: row.resourceType, + name: row.name, + parentId: row.parentId, + locked: row.locked, + sortOrder: row.sortOrder, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt?.toISOString() ?? null, + } +} + +/** + * Renders a folder mutation failure in the v2 error envelope. `locked` keeps its + * 423, matching what the table domain returns when the same mutation lock blocks + * a single-table delete. + */ +export function v2FolderMutationError( + errorCode: FolderMutationErrorCode | undefined, + message: string +): NextResponse { + switch (errorCode) { + case 'validation': + return v2Error('BAD_REQUEST', message) + case 'not_found': + return v2Error('NOT_FOUND', 'Folder not found') + case 'conflict': + return v2Error('CONFLICT', message) + case 'locked': + return v2Error('LOCKED', message) + default: + 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..f9893f60c8e --- /dev/null +++ b/apps/sim/app/api/v2/mcp-servers/route.test.ts @@ -0,0 +1,330 @@ +/** + * @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), + }) + ) +} + +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' }) + }) + + 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":') + }) +}) + +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..73a18037501 --- /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 } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const rows = await listWorkspaceMcpServers({ workspaceId }) + + // 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..6cf0ae6f52f --- /dev/null +++ b/apps/sim/app/api/v2/skills/route.test.ts @@ -0,0 +1,261 @@ +/** + * @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' }) + }) +}) + +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..5e1d6a825b2 --- /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 } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const skills = await listSkills({ workspaceId }) + + // 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/lib/api/contracts/skills.ts b/apps/sim/lib/api/contracts/skills.ts index 31af51cd489..3b29193d87b 100644 --- a/apps/sim/lib/api/contracts/skills.ts +++ b/apps/sim/lib/api/contracts/skills.ts @@ -36,13 +36,13 @@ export const skillEditorSchema = z.object({ export type SkillEditor = z.output -const skillNameSchema = z +export const skillNameSchema = z .string() .min(1, 'Skill name is required') .max(64) .regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'Name must be kebab-case (e.g. my-skill)') -const skillDescriptionSchema = z.string().min(1, 'Description is required').max(1024) -const skillContentSchema = z +export const skillDescriptionSchema = z.string().min(1, 'Description is required').max(1024) +export const skillContentSchema = z .string() .min(1, 'Content is required') .max(50_000, 'Content is too large') diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts new file mode 100644 index 00000000000..205dd794bb7 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -0,0 +1,229 @@ +import { z } from 'zod' +import { + normalizeCredentialEnvKey, + workspaceCredentialRoleSchema, + workspaceCredentialTypeSchema, +} from '@/lib/api/contracts/credentials' +import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { getServiceAccountRequiredFields } from '@/lib/credentials/service-account-fields' + +/** + * v2 credential contracts. + * + * Secret material — service-account JSON, API tokens, signing secrets, bot + * tokens, client secrets — is accepted on write and **never** returned on read, + * the same treatment MCP request headers get. A read exposes only whether a + * secret is stored (`hasServiceAccountKey`). + * + * `oauth` credentials cannot be created here: they are minted by the interactive + * OAuth connect flow and are bound to an `account` row the caller authorized in + * a browser. They are listed, read, updated, and deleted like any other type. + * + * Credential sharing (`/api/credentials/[id]/members`) is not part of this + * surface. + */ + +const ENV_VAR_NAME_REGEX = /^[A-Za-z0-9_]+$/ + +/** The types a public caller can create. `oauth` requires the browser connect flow. */ +export const v2CreatableCredentialTypeSchema = z.enum( + ['env_workspace', 'env_personal', 'service_account'], + { error: 'type must be one of env_workspace, env_personal, service_account' } +) +export type V2CreatableCredentialType = z.output + +/** + * Public credential projection. `workspaceId` (supplied by the caller), + * `createdBy`, and every encrypted column are omitted. + */ +export const v2CredentialSchema = z.object({ + id: z.string(), + type: workspaceCredentialTypeSchema, + displayName: z.string(), + description: z.string().nullable(), + /** The integration this credential authenticates against, when it has one. */ + providerId: z.string().nullable(), + /** The linked OAuth account, for `oauth` credentials. */ + accountId: z.string().nullable(), + /** The environment-variable name, for `env_workspace` / `env_personal` credentials. */ + envKey: z.string().nullable(), + /** Whether a service-account secret is stored. The secret itself is never returned. */ + hasServiceAccountKey: z.boolean(), + /** The caller's role on this credential. */ + role: workspaceCredentialRoleSchema, + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2Credential = z.output + +/** `{ credential }` payload for single-credential reads and mutations. */ +export const v2CredentialDataSchema = z.object({ credential: v2CredentialSchema }) +export type V2CredentialData = z.output + +export const v2CredentialDeleteDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) +export type V2CredentialDeleteData = z.output + +export const v2CredentialParamsSchema = z.object({ + id: nonEmptyIdSchema, +}) +export type V2CredentialParams = z.output + +export const v2CredentialWorkspaceQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) +export type V2CredentialWorkspaceQuery = z.output + +export const v2ListCredentialsQuerySchema = v2CredentialWorkspaceQuerySchema.extend({ + type: workspaceCredentialTypeSchema.optional(), + providerId: z.string().min(1, 'providerId cannot be empty').optional(), +}) +export type V2ListCredentialsQuery = z.output + +/** Write-only secret fields, shared by create and the reconnect-style update. */ +const credentialSecretFields = { + /** Write-only. Google-style service-account JSON key. */ + serviceAccountJson: z.string().min(1, 'serviceAccountJson cannot be empty').optional(), + /** Write-only. Slack custom-bot signing secret. */ + signingSecret: z.string().trim().min(1, 'signingSecret cannot be empty').optional(), + /** Write-only. Slack custom-bot token. */ + botToken: z.string().trim().min(1, 'botToken cannot be empty').optional(), + /** Write-only. Atlassian API token. */ + apiToken: z.string().trim().min(1, 'apiToken cannot be empty').optional(), + domain: z.string().trim().min(1, 'domain cannot be empty').optional(), + /** Write-only. Client-credentials service-account id/secret pair. */ + clientId: z.string().trim().min(1, 'clientId cannot be empty').max(512).optional(), + clientSecret: z.string().trim().min(1, 'clientSecret cannot be empty').max(1024).optional(), + orgId: z.string().trim().min(1, 'orgId cannot be empty').max(255).optional(), +} as const + +export const v2CreateCredentialBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + type: v2CreatableCredentialTypeSchema, + displayName: z.string().trim().min(1).max(255).optional(), + description: z.string().trim().max(500).optional(), + providerId: z.string().trim().min(1, 'providerId cannot be empty').optional(), + /** Required for `env_workspace` / `env_personal`. Accepts `NAME` or `{{NAME}}`. */ + envKey: z.string().trim().min(1, 'envKey cannot be empty').optional(), + ...credentialSecretFields, + }) + .strict() + .superRefine((data, ctx) => { + if (data.type === 'service_account') { + for (const field of getServiceAccountRequiredFields(data.providerId)) { + if (!data[field]) { + ctx.addIssue({ + code: 'custom', + path: [field], + message: `${field} is required for ${data.providerId ?? 'service account'} credentials`, + }) + } + } + return + } + + const normalizedEnvKey = data.envKey ? normalizeCredentialEnvKey(data.envKey) : '' + if (!normalizedEnvKey) { + ctx.addIssue({ + code: 'custom', + path: ['envKey'], + message: 'envKey is required for env credentials', + }) + return + } + if (!ENV_VAR_NAME_REGEX.test(normalizedEnvKey)) { + ctx.addIssue({ + code: 'custom', + path: ['envKey'], + message: 'envKey must contain only letters, numbers, and underscores', + }) + } + }) +export type V2CreateCredentialBody = z.input + +/** + * Update body. Renaming and re-describing apply to any type; the secret fields + * rotate a stored secret in place (the provider re-verifies it). + */ +export const v2UpdateCredentialBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + displayName: z.string().trim().min(1).max(255).optional(), + description: z.string().trim().max(500).nullish(), + ...credentialSecretFields, + }) + .strict() + .superRefine((data, ctx) => { + const { workspaceId: _workspaceId, ...changes } = data + if (Object.values(changes).every((value) => value === undefined)) { + ctx.addIssue({ + code: 'custom', + path: ['displayName'], + message: 'At least one field to change is required', + }) + } + }) +export type V2UpdateCredentialBody = z.input + +/** + * Credential list. A workspace's credential set is small and bounded, so the + * full visible set is returned as a single page (`nextCursor` is always `null`); + * the canonical cursor envelope keeps the v2 list surface uniform. + */ +export const v2ListCredentialsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/credentials', + query: v2ListCredentialsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2CredentialSchema), + }, +}) + +export const v2CreateCredentialContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/credentials', + body: v2CreateCredentialBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CredentialDataSchema), + }, +}) + +export const v2GetCredentialContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/credentials/[id]', + params: v2CredentialParamsSchema, + query: v2CredentialWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CredentialDataSchema), + }, +}) + +export const v2UpdateCredentialContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/credentials/[id]', + params: v2CredentialParamsSchema, + body: v2UpdateCredentialBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CredentialDataSchema), + }, +}) + +export const v2DeleteCredentialContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/credentials/[id]', + params: v2CredentialParamsSchema, + query: v2CredentialWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CredentialDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/custom-tools.ts b/apps/sim/lib/api/contracts/v2/custom-tools.ts new file mode 100644 index 00000000000..7082c6d1c82 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/custom-tools.ts @@ -0,0 +1,148 @@ +import { z } from 'zod' +import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { customToolSchemaSchema } from '@/lib/api/contracts/tools/custom' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 custom tool contracts. + * + * The internal `/api/tools/custom` surface is a bulk upsert with no per-id + * update, and it tolerates legacy *personal* tools (`workspaceId: null`, owned + * by one user) alongside workspace ones. v2 splits create from update and is + * workspace-scoped in every direction — a workspace key never reaches another + * user's personal tool. + * + * The JSON-Schema `schema` field is reused verbatim from the internal contract: + * it is an OpenAI-style function declaration whose `parameters.properties` are + * caller-defined, so the shape is deliberately open below the function level. + */ + +const customToolTitleSchema = z + .string({ error: 'title is required' }) + .min(1, 'title is required') + .max(200, 'title must be at most 200 characters') + +const customToolCodeSchema = z + .string({ error: 'code is required' }) + .max(100_000, 'code must be at most 100000 characters') + +export const v2CustomToolSchema = z.object({ + id: z.string(), + title: z.string(), + /** OpenAI-style function declaration describing the tool's callable surface. */ + schema: customToolSchemaSchema, + /** The tool's implementation body, executed in Sim's sandboxed function runtime. */ + code: z.string(), + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2CustomTool = z.output + +/** `{ customTool }` payload for single-tool reads and mutations. */ +export const v2CustomToolDataSchema = z.object({ customTool: v2CustomToolSchema }) +export type V2CustomToolData = z.output + +export const v2CustomToolDeleteDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) +export type V2CustomToolDeleteData = z.output + +export const v2CustomToolParamsSchema = z.object({ + id: nonEmptyIdSchema, +}) +export type V2CustomToolParams = z.output + +export const v2CustomToolWorkspaceQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) +export type V2CustomToolWorkspaceQuery = z.output + +export const v2CreateCustomToolBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + title: customToolTitleSchema, + schema: customToolSchemaSchema, + code: customToolCodeSchema, + }) + .strict() +export type V2CreateCustomToolBody = z.input + +/** Update body. Omitted fields keep their stored values. */ +export const v2UpdateCustomToolBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + title: customToolTitleSchema.optional(), + schema: customToolSchemaSchema.optional(), + code: customToolCodeSchema.optional(), + }) + .strict() + .superRefine((body, ctx) => { + if (body.title === undefined && body.schema === undefined && body.code === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['title'], + message: 'At least one of title, schema, or code is required', + }) + } + }) +export type V2UpdateCustomToolBody = z.input + +/** + * Custom tool list. The per-workspace set is small and bounded, so the full set + * is returned as a single page (`nextCursor` is always `null`); the canonical + * cursor envelope keeps the v2 list surface uniform. + */ +export const v2ListCustomToolsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/custom-tools', + query: v2CustomToolWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2CustomToolSchema), + }, +}) + +export const v2CreateCustomToolContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/custom-tools', + body: v2CreateCustomToolBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CustomToolDataSchema), + }, +}) + +export const v2GetCustomToolContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/custom-tools/[id]', + params: v2CustomToolParamsSchema, + query: v2CustomToolWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CustomToolDataSchema), + }, +}) + +export const v2UpdateCustomToolContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/custom-tools/[id]', + params: v2CustomToolParamsSchema, + body: v2UpdateCustomToolBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CustomToolDataSchema), + }, +}) + +export const v2DeleteCustomToolContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/custom-tools/[id]', + params: v2CustomToolParamsSchema, + query: v2CustomToolWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CustomToolDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/folders.ts b/apps/sim/lib/api/contracts/v2/folders.ts new file mode 100644 index 00000000000..6f12fe25637 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/folders.ts @@ -0,0 +1,178 @@ +import { z } from 'zod' +import { + folderCascadeCountsSchema, + folderResourceTypeSchema, + folderScopeSchema, + servedFolderResourceTypeSchema, +} from '@/lib/api/contracts/folders' +import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 folder contracts. + * + * One folder engine serves several resource trees (`workflow`, `knowledge_base`, + * `table`), discriminated by `resourceType`. The internal surface defaults that + * field to `workflow` so an old client that never sends it keeps working across + * a deploy; the public surface has no such legacy, and defaulting it would let a + * caller silently file a knowledge-base folder into the workflow tree where the + * Knowledge page can never see it again. So v2 **requires** it on every + * operation, reusing the served enum with its default stripped. + * + * `duplicate`, `restore`, and `reorder` are not part of the public surface. + */ + +/** The served resource types, required rather than defaulted. */ +export const v2FolderResourceTypeSchema = servedFolderResourceTypeSchema.unwrap() +export type V2FolderResourceType = z.output + +/** + * Public folder projection. `userId` (the creator) and `workspaceId` (already + * known to the caller, who supplied it) are internal columns and not exposed. + */ +export const v2FolderSchema = z.object({ + id: z.string(), + resourceType: folderResourceTypeSchema, + name: z.string(), + parentId: z.string().nullable(), + /** Workflow folders only; always `false` for the other resource types. */ + locked: z.boolean(), + sortOrder: z.number(), + createdAt: z.string(), + updatedAt: z.string(), + /** Set when the folder is archived (in Recently Deleted) rather than live. */ + deletedAt: z.string().nullable(), +}) +export type V2Folder = z.output + +/** `{ folder }` payload for single-folder reads and mutations. */ +export const v2FolderDataSchema = z.object({ folder: v2FolderSchema }) +export type V2FolderData = z.output + +/** + * Delete acknowledgement. `deletedItems` reports what the cascade archived + * alongside the folder; only the key matching `resourceType` is populated. + */ +export const v2FolderDeleteDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), + deletedItems: folderCascadeCountsSchema.optional(), +}) +export type V2FolderDeleteData = z.output + +export const v2FolderParamsSchema = z.object({ + id: nonEmptyIdSchema, +}) +export type V2FolderParams = z.output + +/** Query for the id-keyed reads and the delete. */ +export const v2FolderScopedQuerySchema = z.object({ + workspaceId: workspaceIdSchema, + resourceType: v2FolderResourceTypeSchema, +}) +export type V2FolderScopedQuery = z.output + +export const v2ListFoldersQuerySchema = v2FolderScopedQuerySchema.extend({ + /** `active` (default) lists live folders; `archived` lists Recently Deleted. */ + scope: folderScopeSchema.default('active'), +}) +export type V2ListFoldersQuery = z.output + +export const v2CreateFolderBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + resourceType: v2FolderResourceTypeSchema, + name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), + /** Explicit `null` creates the folder at the workspace root. */ + parentId: z.string().min(1, 'parentId cannot be empty').nullable().optional(), + sortOrder: z.number().int('sortOrder must be an integer').min(0).optional(), + }) + .strict() +export type V2CreateFolderBody = z.input + +/** Update body. Omitted fields keep their stored values. */ +export const v2UpdateFolderBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + resourceType: v2FolderResourceTypeSchema, + name: z.string().trim().min(1, 'name cannot be empty').max(255, 'name is too long').optional(), + /** Workflow folders only, and changing it requires workspace `admin`. */ + locked: z.boolean().optional(), + parentId: z.string().min(1, 'parentId cannot be empty').nullable().optional(), + sortOrder: z.number().int('sortOrder must be an integer').min(0).optional(), + }) + .strict() + .superRefine((body, ctx) => { + if ( + body.name === undefined && + body.locked === undefined && + body.parentId === undefined && + body.sortOrder === undefined + ) { + ctx.addIssue({ + code: 'custom', + path: ['name'], + message: 'At least one of name, locked, parentId, or sortOrder is required', + }) + } + }) +export type V2UpdateFolderBody = z.input + +/** + * Folder list. A workspace's folder tree for one resource type is small and + * bounded, so the full set is returned as a single page (`nextCursor` is always + * `null`); the canonical cursor envelope keeps the v2 list surface uniform. + */ +export const v2ListFoldersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/folders', + query: v2ListFoldersQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2FolderSchema), + }, +}) + +export const v2CreateFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/folders', + body: v2CreateFolderBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FolderDataSchema), + }, +}) + +export const v2GetFolderContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/folders/[id]', + params: v2FolderParamsSchema, + query: v2FolderScopedQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FolderDataSchema), + }, +}) + +export const v2UpdateFolderContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/folders/[id]', + params: v2FolderParamsSchema, + body: v2UpdateFolderBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FolderDataSchema), + }, +}) + +export const v2DeleteFolderContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/folders/[id]', + params: v2FolderParamsSchema, + query: v2FolderScopedQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FolderDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/mcp-servers.ts b/apps/sim/lib/api/contracts/v2/mcp-servers.ts new file mode 100644 index 00000000000..54324148896 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/mcp-servers.ts @@ -0,0 +1,217 @@ +import { z } from 'zod' +import { mcpAuthTypeSchema, mcpServerSchema, mcpTransportSchema } from '@/lib/api/contracts/mcp' +import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { createEnvVarPattern } from '@/executor/utils/reference-validation' + +/** + * v2 MCP server contracts. + * + * The routes are thin wrappers over `lib/mcp/orchestration`, but the public + * contract deliberately departs from the internal `/api/mcp/servers` shape in + * four places, each closing a hole that is merely awkward in a browser session + * and unsafe over an API key: + * + * 1. `headers` is write-only. The internal list returns the header map verbatim, + * which is where callers put `Authorization: Bearer …`; reusing that shape + * here would turn a read-scoped key into a token-exfiltration primitive. The + * public read exposes `hasHeaders` and `headerNames` only. + * 2. `url` must be a real absolute `http(s)` URL — the internal body accepts any + * string (or none at all). + * 3. `url` may not carry a `{{ENV_VAR}}` template. `lib/mcp/domain-check` skips + * both the domain allowlist and the SSRF resolve for templated hostnames, + * deferring validation to call time; over an API key that is a stored SSRF + * path. + * 4. Bodies are strict. An unrecognized field is a caller mistake, not something + * to silently pass through to storage. + */ + +/** A `{{ENV_VAR}}` reference anywhere in a URL defers domain/SSRF validation to call time. */ +function hasEnvVarTemplate(value: string): boolean { + return createEnvVarPattern().test(value) +} + +const v2McpServerUrlSchema = z + .string({ error: 'url is required' }) + .min(1, 'url is required') + .max(2048, 'url must be at most 2048 characters') + .refine((value) => !hasEnvVarTemplate(value), { + error: 'url must not contain {{ENV_VAR}} references on the public API', + }) + .refine( + (value) => { + try { + const protocol = new URL(value).protocol + return protocol === 'http:' || protocol === 'https:' + } catch { + return false + } + }, + { error: 'url must be an absolute http or https URL' } + ) + +const v2McpServerHeadersSchema = z.record( + z.string().min(1, 'Header names cannot be empty'), + z.string() +) + +/** + * Public MCP server projection. + * + * The field schemas are picked from {@link mcpServerSchema} so the legacy-row + * tolerance (`.catch()` on the free-text `transport`/`authType`/ + * `connection_status` columns) is shared with the internal surface. The pick is + * re-wrapped in a plain object so the result strips unknown keys instead of + * passing them through — that strip is what keeps `headers` and + * `oauthClientSecret` out of the response when a whole row is handed to it. + */ +export const v2McpServerSchema = z.object({ + ...mcpServerSchema.pick({ + id: true, + name: true, + description: true, + transport: true, + authType: true, + url: true, + timeout: true, + retries: true, + enabled: true, + connectionStatus: true, + lastError: true, + toolCount: true, + lastToolsRefresh: true, + lastConnected: true, + createdAt: true, + updatedAt: true, + oauthClientId: true, + }).shape, + /** Whether any request headers are configured. Values are never returned. */ + hasHeaders: z.boolean(), + /** Names of the configured request headers. Values are never returned. */ + headerNames: z.array(z.string()), + hasOauthClientSecret: z.boolean(), +}) +export type V2McpServer = z.output + +/** `{ mcpServer }` payload for single-server reads and mutations. */ +export const v2McpServerDataSchema = z.object({ mcpServer: v2McpServerSchema }) +export type V2McpServerData = z.output + +/** Delete acknowledgement — the id of the server that was deleted. */ +export const v2McpServerDeleteDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) +export type V2McpServerDeleteData = z.output + +export const v2McpServerParamsSchema = z.object({ + id: nonEmptyIdSchema, +}) +export type V2McpServerParams = z.output + +export const v2McpServerWorkspaceQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) +export type V2McpServerWorkspaceQuery = z.output + +export const v2CreateMcpServerBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: z + .string({ error: 'name is required' }) + .min(1, 'name is required') + .max(255, 'name must be at most 255 characters'), + description: z.string().max(2000, 'description must be at most 2000 characters').optional(), + transport: mcpTransportSchema.optional(), + url: v2McpServerUrlSchema, + authType: mcpAuthTypeSchema.optional(), + /** Write-only. Reads expose `hasHeaders` and `headerNames` instead. */ + headers: v2McpServerHeadersSchema.optional(), + timeout: z + .number() + .int('timeout must be an integer number of milliseconds') + .min(1000, 'timeout must be at least 1000ms') + .max(300000, 'timeout must be at most 300000ms') + .optional(), + retries: z + .number() + .int('retries must be an integer') + .min(0, 'retries cannot be negative') + .max(10, 'retries must be at most 10') + .optional(), + enabled: z.boolean().optional(), + oauthClientId: z.string().max(512, 'oauthClientId is too long').nullable().optional(), + /** Write-only. Reads expose `hasOauthClientSecret` instead. */ + oauthClientSecret: z.string().max(2048, 'oauthClientSecret is too long').nullable().optional(), + }) + .strict() +export type V2CreateMcpServerBody = z.input + +/** + * Update body. Every configuration field is optional; `workspaceId` stays + * required so the request is tenant-scoped before the server id is resolved. + */ +export const v2UpdateMcpServerBodySchema = v2CreateMcpServerBodySchema + .partial() + .extend({ workspaceId: workspaceIdSchema }) + .strict() +export type V2UpdateMcpServerBody = z.input + +/** + * MCP server list. The per-workspace set is small and bounded, so the full set + * is returned as a single page (`nextCursor` is always `null`); the canonical + * cursor envelope keeps the v2 list surface uniform. + */ +export const v2ListMcpServersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/mcp-servers', + query: v2McpServerWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2McpServerSchema), + }, +}) + +export const v2CreateMcpServerContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/mcp-servers', + body: v2CreateMcpServerBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2McpServerDataSchema), + }, +}) + +export const v2GetMcpServerContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/mcp-servers/[id]', + params: v2McpServerParamsSchema, + query: v2McpServerWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2McpServerDataSchema), + }, +}) + +export const v2UpdateMcpServerContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/mcp-servers/[id]', + params: v2McpServerParamsSchema, + body: v2UpdateMcpServerBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2McpServerDataSchema), + }, +}) + +export const v2DeleteMcpServerContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/mcp-servers/[id]', + params: v2McpServerParamsSchema, + query: v2McpServerWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2McpServerDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/skills.ts b/apps/sim/lib/api/contracts/v2/skills.ts new file mode 100644 index 00000000000..3bc7174ea81 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/skills.ts @@ -0,0 +1,156 @@ +import { z } from 'zod' +import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { + skillContentSchema, + skillDescriptionSchema, + skillNameSchema, +} from '@/lib/api/contracts/skills' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 skills contracts. + * + * Two departures from the internal `/api/skills` shape: + * + * 1. **Single-resource writes.** The internal `POST` takes an array, conflates + * create and update, and answers with the whole workspace skill list. v2 + * splits it into `POST /v2/skills` (201) and `PATCH /v2/skills/[id]`, each + * answering with the one skill that changed. + * 2. **`content` is detail-only.** A skill body is up to 50 000 characters, so + * the list returns summaries and the full body is fetched per skill from + * `GET /v2/skills/[id]`. + * + * Field validation lives in `lib/skills/orchestration`, so these schemas and the + * lib enforce the same limits — the schemas reuse the shared field primitives + * rather than restating them. + */ + +/** List item — everything but the skill body. */ +export const v2SkillSummarySchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string(), + /** True for built-in template skills, which ship with Sim and cannot be written to. */ + readOnly: z.boolean(), + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2SkillSummary = z.output + +/** Detail — the summary plus the skill body. */ +export const v2SkillSchema = v2SkillSummarySchema.extend({ + content: z.string(), +}) +export type V2Skill = z.output + +/** `{ skill }` payload for single-skill reads and mutations. */ +export const v2SkillDataSchema = z.object({ skill: v2SkillSchema }) +export type V2SkillData = z.output + +export const v2SkillDeleteDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) +export type V2SkillDeleteData = z.output + +export const v2SkillParamsSchema = z.object({ + id: nonEmptyIdSchema, +}) +export type V2SkillParams = z.output + +export const v2SkillWorkspaceQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) +export type V2SkillWorkspaceQuery = z.output + +export const v2CreateSkillBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: skillNameSchema, + description: skillDescriptionSchema, + content: skillContentSchema, + }) + .strict() +export type V2CreateSkillBody = z.input + +/** + * Update body. Omitted fields keep their stored values, so a partial edit can + * never clobber a concurrent change to a field the caller did not send. + */ +export const v2UpdateSkillBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: skillNameSchema.optional(), + description: skillDescriptionSchema.optional(), + content: skillContentSchema.optional(), + }) + .strict() + .superRefine((body, ctx) => { + if (body.name === undefined && body.description === undefined && body.content === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['name'], + message: 'At least one of name, description, or content is required', + }) + } + }) +export type V2UpdateSkillBody = z.input + +/** + * Skill list. The per-workspace set is small and bounded, so the full set is + * returned as a single page (`nextCursor` is always `null`); the canonical + * cursor envelope keeps the v2 list surface uniform. + */ +export const v2ListSkillsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/skills', + query: v2SkillWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2SkillSummarySchema), + }, +}) + +export const v2CreateSkillContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/skills', + body: v2CreateSkillBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2SkillDataSchema), + }, +}) + +export const v2GetSkillContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/skills/[id]', + params: v2SkillParamsSchema, + query: v2SkillWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2SkillDataSchema), + }, +}) + +export const v2UpdateSkillContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/skills/[id]', + params: v2SkillParamsSchema, + body: v2UpdateSkillBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2SkillDataSchema), + }, +}) + +export const v2DeleteSkillContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/skills/[id]', + params: v2SkillParamsSchema, + query: v2SkillWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2SkillDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts index e3c1c7bfebc..61ab053f07b 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts @@ -1,11 +1,12 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { captureServerEvent } from '@/lib/posthog/server' -import { getSkillActorContext } from '@/lib/skills/access' -import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' -import { deleteSkill, listSkillsForUser, upsertSkills } from '@/lib/workflows/skills/operations' +import { + performCreateSkill, + performDeleteSkill, + performUpdateSkill, +} from '@/lib/skills/orchestration' +import { listSkillsForUser } from '@/lib/workflows/skills/operations' const logger = createLogger('CopilotToolExecutor') @@ -77,35 +78,16 @@ export async function executeManageSkill( } } - const { skills: resultSkills } = await upsertSkills({ - skills: [{ name: params.name, description: params.description, content: params.content }], + const result = await performCreateSkill({ workspaceId, userId: context.userId, + name: params.name, + description: params.description, + content: params.content, + source: 'tool_input', }) - const created = resultSkills.find((s) => s.name === params.name) - - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.SKILL_CREATED, - resourceType: AuditResourceType.SKILL, - resourceId: created?.id, - resourceName: params.name, - description: `Created skill "${params.name}"`, - metadata: { source: 'tool_input' }, - }) - if (created?.id) { - captureServerEvent( - context.userId, - 'skill_created', - { - skill_id: created.id, - skill_name: params.name, - workspace_id: workspaceId, - source: 'tool_input', - }, - { groups: { workspace: workspaceId } } - ) + if (!result.success || !result.skill) { + return { success: false, error: result.error ?? 'Failed to create skill' } } return { @@ -113,9 +95,9 @@ export async function executeManageSkill( output: { success: true, operation, - skillId: created?.id, - name: params.name, - message: `Created skill "${params.name}"`, + skillId: result.skill.id, + name: result.skill.name, + message: `Created skill "${result.skill.name}"`, }, } } @@ -131,66 +113,28 @@ export async function executeManageSkill( } } - if (isBuiltinSkillId(params.skillId)) { - return { success: false, error: 'Built-in skills are read-only and cannot be modified' } - } - - const actor = await getSkillActorContext(params.skillId, context.userId) - if (!actor.skill || actor.skill.workspaceId !== workspaceId || !actor.hasWorkspaceAccess) { - return { success: false, error: `Skill not found: ${params.skillId}` } - } - if (!actor.canEdit) { - return { - success: false, - error: `Permission denied: editing skill "${actor.skill.name}" requires skill editor access. Ask a skill editor to add you.`, - } - } - // Partial update: omitted fields keep their current values server-side. - await upsertSkills({ - skills: [ - { - id: params.skillId, - ...(params.name ? { name: params.name } : {}), - ...(params.description ? { description: params.description } : {}), - ...(params.content ? { content: params.content } : {}), - }, - ], + const result = await performUpdateSkill({ workspaceId, userId: context.userId, + skillId: params.skillId, + ...(params.name ? { name: params.name } : {}), + ...(params.description ? { description: params.description } : {}), + ...(params.content ? { content: params.content } : {}), + source: 'tool_input', }) - - const updatedName = params.name || actor.skill.name - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.SKILL_UPDATED, - resourceType: AuditResourceType.SKILL, - resourceId: params.skillId, - resourceName: updatedName, - description: `Updated skill "${updatedName}"`, - metadata: { source: 'tool_input' }, - }) - captureServerEvent( - context.userId, - 'skill_updated', - { - skill_id: params.skillId, - skill_name: updatedName, - workspace_id: workspaceId, - source: 'tool_input', - }, - { groups: { workspace: workspaceId } } - ) + if (!result.success || !result.skill) { + return { success: false, error: result.error ?? 'Failed to update skill' } + } return { success: true, output: { success: true, operation, - skillId: params.skillId, - name: updatedName, - message: `Updated skill "${updatedName}"`, + skillId: result.skill.id, + name: result.skill.name, + message: `Updated skill "${result.skill.name}"`, }, } } @@ -200,39 +144,15 @@ export async function executeManageSkill( return { success: false, error: "'skillId' is required for 'delete'" } } - if (!isBuiltinSkillId(params.skillId)) { - const actor = await getSkillActorContext(params.skillId, context.userId) - if (!actor.skill || actor.skill.workspaceId !== workspaceId || !actor.hasWorkspaceAccess) { - return { success: false, error: `Skill not found: ${params.skillId}` } - } - if (!actor.canEdit) { - return { - success: false, - error: `Permission denied: deleting skill "${actor.skill.name}" requires skill editor access. Ask a skill editor to add you.`, - } - } - } - - const deleted = await deleteSkill({ skillId: params.skillId, workspaceId }) - if (!deleted) { - return { success: false, error: `Skill not found: ${params.skillId}` } - } - - recordAudit({ + const result = await performDeleteSkill({ workspaceId, - actorId: context.userId, - action: AuditAction.SKILL_DELETED, - resourceType: AuditResourceType.SKILL, - resourceId: params.skillId, - description: 'Deleted skill', - metadata: { source: 'tool_input' }, + userId: context.userId, + skillId: params.skillId, + source: 'tool_input', }) - captureServerEvent( - context.userId, - 'skill_deleted', - { skill_id: params.skillId, workspace_id: workspaceId, source: 'tool_input' }, - { groups: { workspace: workspaceId } } - ) + if (!result.success) { + return { success: false, error: result.error ?? 'Failed to delete skill' } + } return { success: true, diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts new file mode 100644 index 00000000000..07bad9ebfa1 --- /dev/null +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -0,0 +1,588 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { account, credential, credentialMember } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getPostgresErrorCode } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { normalizeCredentialEnvKey } from '@/lib/api/contracts/credentials' +import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' +import { getCredentialCreationWorkspaceContext } from '@/lib/credentials/environment' +import type { CredentialOrchestrationErrorCode } from '@/lib/credentials/orchestration' +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' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('CredentialCreateOrchestration') + +/** + * Credential creation, shared by the session surface (`POST /api/credentials`) + * and the public API (`POST /api/v2/credentials`). + * + * Everything here was previously inline in the session route: the per-type + * source resolution, the existing-credential replay rules, the organization + * mutation locks and in-transaction re-authorization, and the audit. Callers + * render the outcome in their own envelope from `errorCode` / + * `providerErrorCode`. + */ + +type CredentialRow = typeof credential.$inferSelect +type CredentialType = CredentialRow['type'] +type DbOrTx = typeof db | Parameters[0]>[0] + +/** + * Raised by the in-transaction duplicate guard when a concurrent request slipped + * a row in between the outer existence check and the INSERT. + */ +class DuplicateCredentialError extends Error { + constructor() { + super('duplicate_display_name') + this.name = 'DuplicateCredentialError' + } +} + +export interface PerformCreateCredentialParams { + workspaceId: string + type: CredentialType + userId: string + actorName?: string | null + actorEmail?: string | null + displayName?: string + description?: string + providerId?: string + accountId?: string + envKey?: string + envOwnerUserId?: string + serviceAccountJson?: string + apiToken?: string + domain?: string + signingSecret?: string + botToken?: string + clientId?: string + clientSecret?: string + orgId?: string + /** + * Client-supplied credential id, honored only for `slack-custom-bot`: the + * setup modal shows the ingest URL `/api/webhooks/slack/custom/{id}` before + * secrets exist, so the id must be known up front. + */ + id?: string + request?: NextRequest +} + +export interface PerformCreateCredentialResult { + success: boolean + error?: string + errorCode?: CredentialOrchestrationErrorCode + /** Provider-specific code (e.g. Atlassian `invalid_credentials`) for client message mapping. */ + providerErrorCode?: string + /** A provider outage rather than a rejected secret — callers surface 502, not 400. */ + providerUnavailable?: boolean + credential?: CredentialRow + /** False when an existing credential matched the source and was returned instead. */ + created?: boolean +} + +interface ExistingCredentialSourceParams { + workspaceId: string + type: CredentialType + accountId?: string | null + envKey?: string | null + envOwnerUserId?: string | null + displayName?: string | null + providerId?: string | null +} + +/** + * Finds the credential that already occupies a source slot. Each type keys on a + * different tuple, matching the partial unique indexes on the table. + */ +async function findExistingCredentialBySourceWith( + exec: DbOrTx, + params: ExistingCredentialSourceParams +): Promise { + 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 +} + +function failure( + error: string, + errorCode: CredentialOrchestrationErrorCode, + extra: Partial = {} +): PerformCreateCredentialResult { + return { success: false, error, errorCode, ...extra } +} + +export async function performCreateCredential( + params: PerformCreateCredentialParams +): Promise { + const { workspaceId, type, userId } = params + + try { + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + if (!workspaceAccess.canWrite) { + return failure('Write permission required', 'forbidden') + } + + let resolvedDisplayName = params.displayName?.trim() ?? '' + const resolvedDescription = params.description?.trim() || null + let resolvedProviderId: string | null = params.providerId ?? null + let resolvedAccountId: string | null = params.accountId ?? null + const resolvedEnvKey: string | null = params.envKey + ? normalizeCredentialEnvKey(params.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, params.accountId!)) + .limit(1) + + if (!accountRow) return failure('OAuth account not found', 'not_found') + + if (accountRow.userId !== userId) { + return failure( + 'Only account owners can create oauth credentials for an account', + 'forbidden' + ) + } + + if (params.providerId !== accountRow.providerId) { + return failure('providerId does not match the selected OAuth account', 'validation') + } + if (!resolvedDisplayName) { + resolvedDisplayName = + getServiceConfigByProviderId(accountRow.providerId)?.name || accountRow.providerId + } + } else if (type === 'service_account') { + try { + const secret = await verifyAndBuildServiceAccountSecret(params.providerId ?? '', { + signingSecret: params.signingSecret, + botToken: params.botToken, + apiToken: params.apiToken, + domain: params.domain, + serviceAccountJson: params.serviceAccountJson, + clientId: params.clientId, + clientSecret: params.clientSecret, + orgId: params.orgId, + }) + 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 failure(error.message, 'validation') + } + throw error + } + } else if (type === 'env_personal') { + resolvedEnvOwnerUserId = params.envOwnerUserId ?? userId + if (resolvedEnvOwnerUserId !== userId) { + return failure( + 'Only the current user can create personal env credentials for themselves', + 'forbidden' + ) + } + resolvedProviderId = null + resolvedAccountId = null + resolvedDisplayName = resolvedEnvKey || '' + } else { + resolvedProviderId = null + resolvedAccountId = null + resolvedEnvOwnerUserId = null + resolvedDisplayName = resolvedEnvKey || '' + } + + if (!resolvedDisplayName) return failure('Display name is required', 'validation') + + const existingCredential = await findExistingCredentialBySourceWith(db, { + workspaceId, + type, + accountId: resolvedAccountId, + envKey: resolvedEnvKey, + envOwnerUserId: resolvedEnvOwnerUserId, + displayName: resolvedDisplayName, + providerId: resolvedProviderId, + }) + + 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 && + params.id && + existingCredential.id !== params.id + ) { + return failure( + `A Slack bot named "${resolvedDisplayName}" already exists in this workspace. Give this bot a different name.`, + 'conflict', + { providerErrorCode: 'duplicate_display_name' } + ) + } + + /** + * 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 failure( + `A credential named "${resolvedDisplayName}" already exists in this workspace. Give this one a different name.`, + 'conflict', + { providerErrorCode: 'duplicate_display_name' } + ) + } + + const access = await getCredentialActorContext(existingCredential.id, userId, { + workspaceAccess, + }) + + if (!access.member && !access.isAdmin) { + return failure('A credential with this source already exists in this workspace', 'conflict') + } + + const shouldUpdateDisplayName = + type === 'oauth' && + resolvedDisplayName && + resolvedDisplayName !== existingCredential.displayName + const shouldUpdateDescription = + params.description !== undefined && + (existingCredential.description ?? null) !== resolvedDescription + + if (access.isAdmin && (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 { + success: true, + credential: updatedCredential ?? existingCredential, + created: false, + } + } + + return { success: true, credential: existingCredential, created: false } + } + + const now = new Date() + const credentialId = + resolvedProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID && params.id ? params.id : 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, + }) + if (!plannedContext) return failure('Write permission required', 'forbidden') + + await acquireOrganizationUserMutationLocks(tx, { + userId, + organizationIds: plannedContext.organizationId ? [plannedContext.organizationId] : [], + }) + + const currentContext = await getCredentialCreationWorkspaceContext({ + executor: tx, + workspaceId, + userId, + forUpdate: true, + }) + if (!currentContext) return failure('Write permission required', 'forbidden') + if (currentContext.organizationId !== plannedContext.organizationId) { + return failure( + 'Workspace organization changed while creating the credential. Please retry.', + 'conflict' + ) + } + if (!currentContext.canWrite) return failure('Write permission required', 'forbidden') + + /** + * `service_account` has no DB-level unique index on (workspaceId, + * providerId, displayName), so re-check inside the tx. OAuth/env_* are + * guarded by partial unique indexes and fall through to the 23505 handler. + */ + if (type === 'service_account') { + const innerExisting = await findExistingCredentialBySourceWith(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: userId, + createdAt: now, + updatedAt: now, + }) + + if ((type === 'env_workspace' || type === 'service_account') && currentContext.ownerId) { + for (const memberUserId of currentContext.memberUserIds) { + await tx.insert(credentialMember).values({ + id: generateId(), + credentialId, + userId: memberUserId, + role: memberUserId === userId ? 'admin' : 'member', + status: 'active', + joinedAt: now, + invitedBy: userId, + createdAt: now, + updatedAt: now, + }) + } + } else { + await tx.insert(credentialMember).values({ + id: generateId(), + credentialId, + userId, + role: 'admin', + status: 'active', + joinedAt: now, + invitedBy: userId, + createdAt: now, + updatedAt: now, + }) + } + + return { success: true as const } + }) + + if (!creationResult.success) return creationResult + + const [created] = await db + .select() + .from(credential) + .where(eq(credential.id, credentialId)) + .limit(1) + + captureServerEvent( + userId, + 'credential_connected', + { credential_type: type, provider_id: resolvedProviderId ?? type, workspace_id: workspaceId }, + { + groups: { workspace: workspaceId }, + setOnce: { first_credential_connected_at: new Date().toISOString() }, + } + ) + + recordAudit({ + workspaceId, + actorId: userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.CREDENTIAL_CREATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: credentialId, + resourceName: resolvedDisplayName, + description: `Created ${type} credential "${resolvedDisplayName}"`, + metadata: { + credentialType: type, + providerId: resolvedProviderId, + ...extraAuditMetadata, + }, + request: params.request, + }) + + return { success: true, credential: created, created: true } + } catch (error: unknown) { + if (error instanceof AtlassianValidationError) { + logger.warn(`Atlassian credential rejected: ${error.code}`, { + code: error.code, + upstreamStatus: error.status, + ...error.logDetail, + }) + return failure(error.code, 'validation', { + providerErrorCode: error.code, + providerUnavailable: isProviderOutageCode(error.code), + }) + } + if (error instanceof TokenServiceAccountValidationError) { + logger.warn(`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. + return failure(error.code, 'validation', { + providerErrorCode: error.code, + providerUnavailable: isProviderOutageCode(error.code), + }) + } + if (error instanceof DuplicateCredentialError) { + return failure('A credential with that name already exists in this workspace.', 'conflict', { + providerErrorCode: 'duplicate_display_name', + }) + } + + const pgCode = getPostgresErrorCode(error) + if (pgCode === '23505') { + return failure('A credential with this source already exists', 'conflict') + } + if (pgCode === '23503') { + return failure('Invalid credential reference or membership target', 'validation') + } + if (pgCode === '23514') { + return failure('Credential source data failed validation checks', 'validation') + } + + const errAsRecord = + typeof error === 'object' && error !== null ? (error as Record) : {} + logger.error('Credential create failure details', { + code: pgCode, + detail: errAsRecord.detail, + constraint: errAsRecord.constraint, + table: errAsRecord.table, + message: errAsRecord.message, + }) + logger.error('Failed to create credential', { error }) + return failure('Internal server error', 'internal') + } +} + +/** + * Provider error codes that mean the upstream service could not be reached, + * rather than that the caller's secret was rejected. Each provider family names + * its own — Atlassian raises `atlassian_unavailable`, the token service accounts + * raise `provider_unavailable` — and both must map to 503, not 400. Kept as one + * set so a new provider family is added in a single place instead of being + * missed on whichever call path nobody re-checked. + */ +const PROVIDER_OUTAGE_CODES = new Set(['provider_unavailable', 'atlassian_unavailable']) + +export function isProviderOutageCode(code: string | undefined): boolean { + return code !== undefined && PROVIDER_OUTAGE_CODES.has(code) +} + +/** HTTP status for a credential orchestration failure, shared by every route surface. */ +export function statusForCredentialOrchestrationError( + code: CredentialOrchestrationErrorCode | undefined, + options: { providerUnavailable?: boolean } = {} +): number { + if (options.providerUnavailable) return 502 + if (code === 'validation') return 400 + if (code === 'forbidden') return 403 + if (code === 'not_found') return 404 + if (code === 'conflict') return 409 + return 500 +} diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index a26a190f126..e0c6a375e39 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -22,6 +22,14 @@ import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('CredentialOrchestration') +export { + isProviderOutageCode, + type PerformCreateCredentialParams, + type PerformCreateCredentialResult, + performCreateCredential, + statusForCredentialOrchestrationError, +} from './credential-create' + export type CredentialOrchestrationErrorCode = | 'not_found' | 'forbidden' diff --git a/apps/sim/lib/credentials/queries.ts b/apps/sim/lib/credentials/queries.ts new file mode 100644 index 00000000000..397e96b9f59 --- /dev/null +++ b/apps/sim/lib/credentials/queries.ts @@ -0,0 +1,114 @@ +import { db } from '@sim/db' +import { credential, credentialMember } from '@sim/db/schema' +import { and, eq, inArray, isNotNull, or } from 'drizzle-orm' +import type { WorkspaceCredentialType } from '@/lib/api/contracts/credentials' +import { isSharedCredentialType, SHARED_CREDENTIAL_TYPES } from '@/lib/credentials/access' +import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +/** + * Workspace-scoped credential reads shared by the session surface and the public + * API, so the visibility rules cannot drift between them. + */ + +export type CredentialRow = typeof credential.$inferSelect + +export interface VisibleWorkspaceCredential { + id: string + workspaceId: string + type: CredentialRow['type'] + displayName: string + description: string | null + providerId: string | null + accountId: string | null + envKey: string | null + envOwnerUserId: string | null + createdBy: string + createdAt: Date + updatedAt: Date + hasServiceAccountKey: boolean + role: 'admin' | 'member' +} + +/** + * The credentials a user may see in a workspace. + * + * Visibility is an explicit `credential_member` row, plus — for workspace + * admins — every shared-type credential, plus the caller's own personal env + * credentials. Encrypted secret material is never selected. + */ +export async function listVisibleWorkspaceCredentials(params: { + workspaceId: string + userId: string + workspaceAccess: Pick + type?: WorkspaceCredentialType + providerId?: string +}): Promise { + const { workspaceId, userId, workspaceAccess, type, providerId } = params + + 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, userId) + ) + : or(isNotNull(credentialMember.id), eq(credential.envOwnerUserId, userId)) + + 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, + encryptedServiceAccountKey: credential.encryptedServiceAccountKey, + memberRole: credentialMember.role, + }) + .from(credential) + .leftJoin( + credentialMember, + and( + eq(credentialMember.credentialId, credential.id), + eq(credentialMember.userId, userId), + eq(credentialMember.status, 'active') + ) + ) + .where(and(...whereClauses, accessClause)) + + return rows.map(({ memberRole, encryptedServiceAccountKey, ...rest }) => ({ + ...rest, + hasServiceAccountKey: Boolean(encryptedServiceAccountKey), + role: + isWorkspaceAdmin && isSharedCredentialType(rest.type) ? 'admin' : (memberRole ?? 'member'), + })) +} + +/** + * A single credential scoped to a workspace, or null when it does not exist + * there. Scoping by workspace is what keeps a credential id from another tenant + * from resolving at all. + */ +export async function getWorkspaceCredential(params: { + workspaceId: string + credentialId: string +}): Promise { + const [row] = await db + .select() + .from(credential) + .where( + and(eq(credential.id, params.credentialId), eq(credential.workspaceId, params.workspaceId)) + ) + .limit(1) + return row ?? null +} diff --git a/apps/sim/lib/folders/queries.ts b/apps/sim/lib/folders/queries.ts index 77cc4392756..092387cea90 100644 --- a/apps/sim/lib/folders/queries.ts +++ b/apps/sim/lib/folders/queries.ts @@ -83,6 +83,34 @@ export async function findActiveFolder( return row ?? null } +/** + * A folder in a workspace's tree regardless of archive state. + * + * {@link findActiveFolder} answers "is this a valid destination"; this answers "does this row + * exist here at all". Delete needs the second question — `deleteFolder` reuses an already + * archived folder's own `deletedAt` so a cascade that failed partway can be retried, and + * filtering archived rows out would strand those stragglers. + */ +export async function findFolderInWorkspace( + folderId: string, + workspaceId: string, + resourceType: FolderResourceType +): Promise { + const [row] = await db + .select() + .from(folder) + .where( + and( + eq(folder.id, folderId), + eq(folder.workspaceId, workspaceId), + eq(folder.resourceType, resourceType) + ) + ) + .limit(1) + + return row ?? null +} + /** * Where a restored resource should land: its original folder when that folder is reachable, * otherwise the workspace root. diff --git a/apps/sim/lib/mcp/queries.ts b/apps/sim/lib/mcp/queries.ts new file mode 100644 index 00000000000..81a50f0b1d6 --- /dev/null +++ b/apps/sim/lib/mcp/queries.ts @@ -0,0 +1,63 @@ +import { db } from '@sim/db' +import { mcpServers } from '@sim/db/schema' +import { and, desc, eq, isNull } from 'drizzle-orm' + +/** + * Workspace-scoped MCP server reads. The lifecycle functions in + * `lib/mcp/orchestration` cover the write paths; these cover the read paths the + * public API needs without duplicating the scoping predicate per route. + */ + +export type McpServerRow = typeof mcpServers.$inferSelect + +/** Live (non-soft-deleted) MCP servers in a workspace, newest first. */ +export async function listWorkspaceMcpServers(params: { + workspaceId: string +}): Promise { + return db + .select() + .from(mcpServers) + .where(and(eq(mcpServers.workspaceId, params.workspaceId), isNull(mcpServers.deletedAt))) + .orderBy(desc(mcpServers.createdAt)) +} + +/** A single live MCP server, or null when it does not exist in this workspace. */ +export async function getWorkspaceMcpServer(params: { + workspaceId: string + serverId: string +}): Promise { + const [row] = await db + .select() + .from(mcpServers) + .where( + and( + eq(mcpServers.id, params.serverId), + eq(mcpServers.workspaceId, params.workspaceId), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + return row ?? null +} + +/** + * The state of the row occupying the deterministic id derived from a workspace + * and URL, or null when the id is free. + * + * The soft-deleted case has to be distinguished rather than merged into "taken": + * `performCreateMcpServer` revives such a row instead of inserting alongside it, + * so reporting it as a duplicate would make a soft-deleted URL permanently + * unusable — it cannot be fetched or patched either, since those resolve live + * rows only. + */ +export async function getMcpServerIdState(params: { + workspaceId: string + serverId: string +}): Promise<{ deleted: boolean } | null> { + const [row] = await db + .select({ deletedAt: mcpServers.deletedAt }) + .from(mcpServers) + .where(and(eq(mcpServers.id, params.serverId), eq(mcpServers.workspaceId, params.workspaceId))) + .limit(1) + return row ? { deleted: row.deletedAt !== null } : null +} diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index aa520209e87..d6383133d8f 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -197,20 +197,20 @@ export interface PostHogEventMap { skill_id: string skill_name: string workspace_id: string - source?: 'settings' | 'tool_input' + source?: 'settings' | 'tool_input' | 'api' } skill_updated: { skill_id: string skill_name: string workspace_id: string - source?: 'settings' | 'tool_input' + source?: 'settings' | 'tool_input' | 'api' } skill_deleted: { skill_id: string workspace_id: string - source?: 'settings' | 'tool_input' + source?: 'settings' | 'tool_input' | 'api' } skill_shared: { diff --git a/apps/sim/lib/skills/orchestration/index.ts b/apps/sim/lib/skills/orchestration/index.ts new file mode 100644 index 00000000000..48bf621ec27 --- /dev/null +++ b/apps/sim/lib/skills/orchestration/index.ts @@ -0,0 +1,12 @@ +export { + type PerformCreateSkillParams, + type PerformDeleteSkillParams, + type PerformSkillResult, + type PerformUpdateSkillParams, + performCreateSkill, + performDeleteSkill, + performUpdateSkill, + type SkillOrchestrationErrorCode, + type SkillWriteSource, + statusForSkillOrchestrationError, +} from './skill-lifecycle' diff --git a/apps/sim/lib/skills/orchestration/skill-lifecycle.ts b/apps/sim/lib/skills/orchestration/skill-lifecycle.ts new file mode 100644 index 00000000000..b45d6b7db75 --- /dev/null +++ b/apps/sim/lib/skills/orchestration/skill-lifecycle.ts @@ -0,0 +1,359 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import type { skill } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import type { z } from 'zod' +import { + skillContentSchema, + skillDescriptionSchema, + skillNameSchema, +} from '@/lib/api/contracts/skills' +import { captureServerEvent } from '@/lib/posthog/server' +import { getSkillActorContext } from '@/lib/skills/access' +import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' +import { getBuiltinSkillByName, isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' +import { deleteSkill, getSkillById, upsertSkills } from '@/lib/workflows/skills/operations' + +const logger = createLogger('SkillOrchestration') + +/** + * Single authority for skill create/update/delete. + * + * Before this module the API route owned the create-vs-update split, the + * built-in guard, the per-skill editor check, the field limits (which lived + * only in the route's Zod contract), and the audit — so the copilot's + * `manage_skill`, which calls `upsertSkills` directly, bypassed all of them. + * Every caller now goes through these functions and gets the same rules. + * + * Workspace-level authorization stays with the caller: each surface has already + * established workspace access by the time it gets here (session middleware, + * the v2 `resolveWorkspaceAccess`, the copilot's permission context). What is + * owned here is everything *per skill*. + */ + +/** + * Skills need a `forbidden` outcome the shared code set does not carry: a + * caller can hold workspace write and still not be an editor of a given skill. + */ +export type SkillOrchestrationErrorCode = OrchestrationErrorCode | 'forbidden' + +/** HTTP status for a skill orchestration failure, shared by every route surface. */ +export function statusForSkillOrchestrationError( + code: SkillOrchestrationErrorCode | undefined +): number { + if (code === 'validation') return 400 + if (code === 'forbidden') return 403 + if (code === 'not_found') return 404 + if (code === 'conflict') return 409 + return 500 +} + +type SkillRow = typeof skill.$inferSelect + +/** Which surface performed the write. Recorded on the audit entry and the analytics event. */ +export type SkillWriteSource = 'settings' | 'tool_input' | 'api' + +interface ActorMetadata { + actorName?: string | null + actorEmail?: string | null + source?: SkillWriteSource + request?: NextRequest +} + +export interface PerformCreateSkillParams extends ActorMetadata { + workspaceId: string + userId: string + name: string + description: string + content: string +} + +export interface PerformUpdateSkillParams extends ActorMetadata { + workspaceId: string + userId: string + skillId: string + name?: string + description?: string + content?: string +} + +export interface PerformDeleteSkillParams extends ActorMetadata { + workspaceId: string + userId: string + skillId: string +} + +export interface PerformSkillResult { + success: boolean + error?: string + errorCode?: SkillOrchestrationErrorCode + skill?: SkillRow +} + +function validationFailure(error: string): PerformSkillResult { + return { success: false, error, errorCode: 'validation' } +} + +/** First message from a failed field parse, or null when the value is valid. */ +function fieldError(schema: z.ZodType, value: unknown): string | null { + const parsed = schema.safeParse(value) + return parsed.success ? null : (parsed.error.issues[0]?.message ?? 'Invalid value') +} + +/** + * A workspace skill sharing a built-in's name silently shadows it everywhere the + * two lists are merged. Reject the collision at the write instead of resolving + * it at every read. + */ +function builtinNameCollision(name: string): string | null { + return getBuiltinSkillByName(name) + ? `The skill name "${name}" is reserved by a built-in skill` + : null +} + +/** + * Resolves the acting user's edit rights over an existing workspace skill. + * Returns the loaded row, or the failure to surface. + */ +async function resolveEditableSkill(params: { + workspaceId: string + userId: string + skillId: string +}): Promise<{ ok: true; skill: SkillRow } | { ok: false; result: PerformSkillResult }> { + if (isBuiltinSkillId(params.skillId)) { + return { + ok: false, + result: validationFailure('Built-in skills are read-only and cannot be modified'), + } + } + + const actor = await getSkillActorContext(params.skillId, params.userId) + if (!actor.skill || actor.skill.workspaceId !== params.workspaceId || !actor.hasWorkspaceAccess) { + return { + ok: false, + result: { success: false, error: 'Skill not found', errorCode: 'not_found' }, + } + } + if (!actor.canEdit) { + return { + ok: false, + result: { + success: false, + error: `Skill editor access required to modify "${actor.skill.name}"`, + errorCode: 'forbidden', + }, + } + } + return { ok: true, skill: actor.skill } +} + +/** + * `upsertSkills` reports name collisions and vanished ids as thrown Errors. + * Classify them rather than letting every caller re-match the message. + * + * The `23505` arm covers the race its in-transaction name `SELECT` cannot: two + * concurrent creates (or renames) both pass that check, and the loser is rejected + * by `skill_workspace_name_unique` as a raw Postgres error whose message matches + * nothing here — which would otherwise surface as a 500 for what is a conflict. + */ +function classifyUpsertError(error: unknown): PerformSkillResult { + const message = getErrorMessage(error, 'Failed to save skill') + if (getPostgresErrorCode(error) === '23505') { + return { + success: false, + error: 'That skill name is unavailable in this workspace', + errorCode: 'conflict', + } + } + if (message.includes('is unavailable')) { + return { success: false, error: message, errorCode: 'conflict' } + } + if (message.startsWith('Skill not found')) { + return { success: false, error: 'Skill not found', errorCode: 'not_found' } + } + logger.error('Skill upsert failed', { error: message }) + return { success: false, error: 'Failed to save skill', errorCode: 'internal' } +} + +type SkillLifecycleAction = 'created' | 'updated' | 'deleted' + +const AUDIT_ACTION = { + created: AuditAction.SKILL_CREATED, + updated: AuditAction.SKILL_UPDATED, + deleted: AuditAction.SKILL_DELETED, +} as const satisfies Record + +const AUDIT_VERB = { + created: 'Created', + updated: 'Updated', + deleted: 'Deleted', +} as const satisfies Record + +function recordSkillEvent(params: { + action: SkillLifecycleAction + workspaceId: string + userId: string + skillId: string + skillName: string + actor: ActorMetadata +}): void { + const { action, workspaceId, userId, skillId, skillName, actor } = params + + recordAudit({ + workspaceId, + actorId: userId, + actorName: actor.actorName ?? undefined, + actorEmail: actor.actorEmail ?? undefined, + action: AUDIT_ACTION[action], + resourceType: AuditResourceType.SKILL, + resourceId: skillId, + resourceName: skillName, + description: `${AUDIT_VERB[action]} skill "${skillName}"`, + metadata: { source: actor.source }, + request: actor.request, + }) + + // The delete event carries no skill_name — the skill no longer exists to name. + if (action === 'deleted') { + captureServerEvent( + userId, + 'skill_deleted', + { skill_id: skillId, workspace_id: workspaceId, source: actor.source }, + { groups: { workspace: workspaceId } } + ) + return + } + + captureServerEvent( + userId, + action === 'created' ? 'skill_created' : 'skill_updated', + { + skill_id: skillId, + skill_name: skillName, + workspace_id: workspaceId, + source: actor.source, + }, + { groups: { workspace: workspaceId } } + ) +} + +export async function performCreateSkill( + params: PerformCreateSkillParams +): Promise { + const invalid = + fieldError(skillNameSchema, params.name) ?? + fieldError(skillDescriptionSchema, params.description) ?? + fieldError(skillContentSchema, params.content) ?? + builtinNameCollision(params.name) + if (invalid) return validationFailure(invalid) + + let created: { id: string; name: string } | undefined + try { + const { touched } = await upsertSkills({ + skills: [{ name: params.name, description: params.description, content: params.content }], + workspaceId: params.workspaceId, + userId: params.userId, + returnSkills: false, + }) + created = touched[0] + } catch (error) { + return classifyUpsertError(error) + } + + if (!created) { + logger.error('Skill create returned no touched row', { workspaceId: params.workspaceId }) + return { success: false, error: 'Failed to create skill', errorCode: 'internal' } + } + + recordSkillEvent({ + action: 'created', + workspaceId: params.workspaceId, + userId: params.userId, + skillId: created.id, + skillName: created.name, + actor: params, + }) + + const row = await getSkillById({ skillId: created.id, workspaceId: params.workspaceId }) + if (!row) return { success: false, error: 'Failed to create skill', errorCode: 'internal' } + return { success: true, skill: row } +} + +export async function performUpdateSkill( + params: PerformUpdateSkillParams +): Promise { + if ( + params.name === undefined && + params.description === undefined && + params.content === undefined + ) { + return validationFailure('At least one of name, description, or content is required') + } + + const invalid = + (params.name !== undefined + ? (fieldError(skillNameSchema, params.name) ?? builtinNameCollision(params.name)) + : null) ?? + (params.description !== undefined + ? fieldError(skillDescriptionSchema, params.description) + : null) ?? + (params.content !== undefined ? fieldError(skillContentSchema, params.content) : null) + if (invalid) return validationFailure(invalid) + + const resolved = await resolveEditableSkill(params) + if (!resolved.ok) return resolved.result + + try { + await upsertSkills({ + skills: [ + { + id: params.skillId, + ...(params.name !== undefined ? { name: params.name } : {}), + ...(params.description !== undefined ? { description: params.description } : {}), + ...(params.content !== undefined ? { content: params.content } : {}), + }, + ], + workspaceId: params.workspaceId, + userId: params.userId, + returnSkills: false, + }) + } catch (error) { + return classifyUpsertError(error) + } + + const row = await getSkillById({ skillId: params.skillId, workspaceId: params.workspaceId }) + if (!row) return { success: false, error: 'Skill not found', errorCode: 'not_found' } + + recordSkillEvent({ + action: 'updated', + workspaceId: params.workspaceId, + userId: params.userId, + skillId: row.id, + skillName: row.name, + actor: params, + }) + + return { success: true, skill: row } +} + +export async function performDeleteSkill( + params: PerformDeleteSkillParams +): Promise { + const resolved = await resolveEditableSkill(params) + if (!resolved.ok) return resolved.result + + const deleted = await deleteSkill({ skillId: params.skillId, workspaceId: params.workspaceId }) + if (!deleted) return { success: false, error: 'Skill not found', errorCode: 'not_found' } + + recordSkillEvent({ + action: 'deleted', + workspaceId: params.workspaceId, + userId: params.userId, + skillId: params.skillId, + skillName: resolved.skill.name, + actor: params, + }) + + return { success: true, skill: resolved.skill } +} diff --git a/apps/sim/lib/workflows/custom-tools/operations.ts b/apps/sim/lib/workflows/custom-tools/operations.ts index 8fbccef1b43..2b6a779776b 100644 --- a/apps/sim/lib/workflows/custom-tools/operations.ts +++ b/apps/sim/lib/workflows/custom-tools/operations.ts @@ -128,6 +128,86 @@ export async function listCustomTools(params: { userId: string; workspaceId?: st .orderBy(desc(customTools.createdAt)) } +/** + * Workspace-scoped reads and deletes. + * + * The functions above tolerate legacy personal tools (`workspace_id IS NULL`, + * owned by one user) alongside workspace ones. The public API is workspace- + * scoped in every direction, so it uses these instead — a caller holding a + * workspace key must never reach another user's personal tool. + */ +export async function listWorkspaceCustomTools(params: { workspaceId: string }) { + return db + .select() + .from(customTools) + .where(eq(customTools.workspaceId, params.workspaceId)) + .orderBy(desc(customTools.createdAt)) +} + +export async function getWorkspaceCustomTool(params: { workspaceId: string; toolId: string }) { + const [row] = await db + .select() + .from(customTools) + .where(and(eq(customTools.id, params.toolId), eq(customTools.workspaceId, params.workspaceId))) + .limit(1) + return row ?? null +} + +/** Titles are unique per workspace (`custom_tools_workspace_title_unique`). */ +export async function getWorkspaceCustomToolByTitle(params: { + workspaceId: string + title: string +}) { + const [row] = await db + .select() + .from(customTools) + .where( + and(eq(customTools.workspaceId, params.workspaceId), eq(customTools.title, params.title)) + ) + .limit(1) + return row ?? null +} + +/** + * Updates a workspace tool in place, returning the updated row or null when the + * id no longer resolves in that workspace. + * + * Deliberately not `upsertCustomTools`: that treats an unresolvable id as a + * create and inserts under a *new* id, so a tool deleted concurrently with an + * edit would be silently re-created as an orphan under a different id while the + * caller's follow-up read of the original id 404s. + */ +export async function updateWorkspaceCustomTool(params: { + workspaceId: string + toolId: string + title: string + schema: unknown + code: string +}) { + const [row] = await db + .update(customTools) + .set({ + title: params.title, + schema: params.schema, + code: params.code, + updatedAt: new Date(), + }) + .where(and(eq(customTools.id, params.toolId), eq(customTools.workspaceId, params.workspaceId))) + .returning() + return row ?? null +} + +export async function deleteWorkspaceCustomTool(params: { + workspaceId: string + toolId: string +}): Promise { + const deleted = await db + .delete(customTools) + .where(and(eq(customTools.id, params.toolId), eq(customTools.workspaceId, params.workspaceId))) + .returning({ id: customTools.id }) + return deleted.length > 0 +} + export async function getCustomToolById(params: { toolId: string userId: string diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 0cea7a4ec91..137f60f185b 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1028, - zodRoutes: 1028, + totalRoutes: 1038, + zodRoutes: 1038, nonZodRoutes: 0, } as const diff --git a/scripts/check-openapi-specs.ts b/scripts/check-openapi-specs.ts index 3387f058cb9..5fa5ad826a5 100644 --- a/scripts/check-openapi-specs.ts +++ b/scripts/check-openapi-specs.ts @@ -38,6 +38,7 @@ const SPEC_FILES = [ 'openapi-v2-tables.json', 'openapi-v2-knowledge.json', 'openapi-v2-files-audit.json', + 'openapi-v2-resources.json', ] /** Extra non-v2 contracts that are documented in the core spec. */ From ab6684fdb21771a3ddb5d80b96ba20243f5aaf0b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 00:03:53 -0700 Subject: [PATCH 19/24] fix(contracts): anchor the predicate double-cast annotation to the cast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:api-validation:strict` counted 9 unannotated double-casts against a baseline of 8, failing CI. The predicate leaf schema was annotated, but the annotation sat above the declaration while the checker anchors on the line carrying the cast — five lines below, at the close of the object literal. The scanner walks back at most three lines and stops at the first non-comment one, so it hit `value: z.unknown().optional(),` and never saw the reason. Splitting the object schema from the cast puts them adjacent, so the existing reason binds. No behavior change — the cast, the schema, and the reasoning are unchanged. Also lowers the rawJsonReads ratchet 6 -> 5 to match the current count, which had drifted down; leaving it high lets a removed raw read silently come back. Co-Authored-By: Claude Opus 5 --- apps/sim/lib/api/contracts/tables.ts | 12 +++++++----- scripts/check-api-validation-contracts.ts | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 075a1a8a199..89e68b9715f 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -477,14 +477,16 @@ function predicateTreeTooLarge(root: unknown): string | null { * before it ran. Strict on BOTH branches is required: strict on the group alone * would just fall through to the leaf branch, which is the more dangerous reading. */ -// double-cast-allowed: `z.unknown()` keeps the runtime permissive (a leaf value -// is arbitrary JSON), but infers `unknown`, which is wider than -// `Predicate['value']`. The narrowing is type-level only — nothing is coerced. -const predicateLeafSchema = z.strictObject({ +const predicateLeafObjectSchema = z.strictObject({ field: z.string().min(1, 'field is required').max(128), op: z.enum(FILTER_OPS), value: z.unknown().optional(), -}) as unknown as z.ZodType +}) + +// double-cast-allowed: `z.unknown()` keeps the runtime permissive (a leaf value +// is arbitrary JSON), but infers `unknown`, which is wider than +// `Predicate['value']`. The narrowing is type-level only — nothing is coerced. +const predicateLeafSchema = predicateLeafObjectSchema as unknown as z.ZodType const predicateNodeSchema: z.ZodType = z.lazy(() => z.union([predicateGroupSchema, predicateLeafSchema]) diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 0cea7a4ec91..67020777e2e 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -25,7 +25,7 @@ const BOUNDARY_POLICY_BASELINE = { clientHookRawFetches: 0, clientSameOriginApiFetches: 0, doubleCasts: 8, - rawJsonReads: 6, + rawJsonReads: 5, untypedResponses: 0, annotationsMissingReason: 0, } as const From 29b64c6cc6f82339994b3a9c00189f2e73d3b65b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 00:12:30 -0700 Subject: [PATCH 20/24] fix(skills): point the orchestration error contract at its moved module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #6150 branched before #6134, so skill-lifecycle.ts imports @/lib/workflows/orchestration/types — the module #6134 moved to @/lib/core/orchestration/types. Git merged a file deletion on one side with a new file referencing it on the other: no textual conflict, broken build. Co-Authored-By: Claude Opus 5 --- apps/sim/lib/skills/orchestration/skill-lifecycle.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/skills/orchestration/skill-lifecycle.ts b/apps/sim/lib/skills/orchestration/skill-lifecycle.ts index b45d6b7db75..5cb13daf037 100644 --- a/apps/sim/lib/skills/orchestration/skill-lifecycle.ts +++ b/apps/sim/lib/skills/orchestration/skill-lifecycle.ts @@ -9,9 +9,9 @@ import { skillDescriptionSchema, skillNameSchema, } from '@/lib/api/contracts/skills' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { captureServerEvent } from '@/lib/posthog/server' import { getSkillActorContext } from '@/lib/skills/access' -import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' import { getBuiltinSkillByName, isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' import { deleteSkill, getSkillById, upsertSkills } from '@/lib/workflows/skills/operations' From edd9a06707c30314456bf57442a0caa4bfb2204b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 01:02:50 -0700 Subject: [PATCH 21/24] refactor(knowledge): make lib/knowledge/orchestration the single implementation (#6154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(knowledge): make lib/knowledge/orchestration the single implementation Knowledge base create was implemented four times — the internal route, v1, v2, and the copilot tool — and the orchestration around the shared write had drifted. Extract it the same way lib/table/orchestration was: services write, orchestration decides which writes run, guards them, audits them, and returns a transport-neutral failure. Behavior converged, not preserved: - One chunking default (DEFAULT_CHUNKING_CONFIG). The agent defaulted minSize to 1 against the API's 100, so identical input produced differently-chunked knowledge bases depending on who created it. The agent path now chunks at 100. - Every successful mutation is audited inside the orchestration function. The copilot tool called recordAudit zero times, so agent-created knowledge bases, document uploads, updates and deletes left no audit trail at all. - Failures classify by class, not by message text. The knowledge service errors are OrchestrationError subclasses and storage-quota rejections throw a shared StorageLimitExceededError, replacing four separate message greps for "already exists" / "does not have permission" / "storage limit". delete_connector reported the opposite of what happened. It reached the route through an internal HTTP self-call that sent no query string, so the route's keep-documents default always applied while the agent told the user the documents had been removed. The self-call is gone — all four connector operations run in-process — and the orchestration returns the real counts. Also: - OrchestrationErrorCode gains 'payload_too_large' (413 / PAYLOAD_TOO_LARGE). Without it, dropping the storage-limit message match would have regressed the documented 413 on knowledge base create and document upload to a 500. - messageForOrchestrationError renders a route's own wording for an unclassified fault, so a driver's message no longer reaches the client on a 500. - v1 and v2 knowledge base update now forward actorUserId, which the service requires for a workspace move; both omitted it. - The connector DELETE route reads deleteDocuments through parseRequest. Its contract declared z.boolean(), which would have rejected the string a query param actually is. - Drop the 409 from POST /api/v2/knowledge/{id}/documents in the OpenAPI spec. Nothing on the upload path throws a conflict; it was only ever reachable by the message match this change removes. Behavior change worth noting: a v1/v2 PUT carrying only the workspaceId scope field and no actual updates now returns 400 rather than 200 with the unchanged knowledge base. Deliberately deferred: document update remains internal-only. Extracting performUpdateKnowledgeDocument makes exposing it on v1/v2 a contract and a route away, but that is a new public surface rather than part of this consolidation. * fix(knowledge): make connector create atomic and stop flattening failures Review round 1 on #6154. - Resolve the billing payer before the connector is committed, not after. A malformed attribution header rejected post-commit left a live connector behind a 500, and a retry created a duplicate plus duplicate sync work. Manual sync resolves before writing its audit for the same reason. - Let the source-config validator carry its own failure class. Collapsing every rejection to `validation` flattened the connector PATCH route's 401 (stale stored credential) and 409 (missing workspace context) into a 400. - Add `unauthorized` to OrchestrationErrorCode. It is the class that 401 was already expressing on this route, and the v2 vocabulary already had UNAUTHORIZED; only the shared union was missing it. - Report a knowledge base that exists but failed to archive as failed, with the reason, rather than as not found. The copilot delete loop folded every non-not-found failure into `notFound`, telling the user it was never there. - Route copilot failures through the same message helper the HTTP surfaces use, so an unclassified fault's raw text (a driver's failed SQL) no longer reaches the agent verbatim while the UI and public APIs get the generic wording. --- apps/docs/openapi-v2-knowledge.json | 3 - .../connectors/[connectorId]/route.test.ts | 9 +- .../[id]/connectors/[connectorId]/route.ts | 511 ++++-------- .../[connectorId]/sync/route.test.ts | 10 +- .../connectors/[connectorId]/sync/route.ts | 145 +--- .../knowledge/[id]/connectors/route.test.ts | 5 +- .../api/knowledge/[id]/connectors/route.ts | 319 ++------ .../[id]/documents/[documentId]/route.ts | 198 ++--- .../knowledge/[id]/documents/route.test.ts | 4 +- .../app/api/knowledge/[id]/documents/route.ts | 199 ++--- .../app/api/knowledge/[id]/restore/route.ts | 15 +- apps/sim/app/api/knowledge/[id]/route.ts | 231 +++--- apps/sim/app/api/knowledge/route.ts | 166 ++-- .../[id]/documents/[documentId]/route.ts | 34 +- .../v1/knowledge/[id]/documents/route.test.ts | 3 + .../api/v1/knowledge/[id]/documents/route.ts | 61 +- apps/sim/app/api/v1/knowledge/[id]/route.ts | 68 +- apps/sim/app/api/v1/knowledge/route.ts | 45 +- .../[id]/documents/[documentId]/route.ts | 35 +- .../api/v2/knowledge/[id]/documents/route.ts | 67 +- apps/sim/app/api/v2/knowledge/[id]/route.ts | 75 +- apps/sim/app/api/v2/knowledge/route.ts | 61 +- apps/sim/app/api/v2/lib/response.ts | 2 + apps/sim/lib/api/contracts/knowledge/base.ts | 11 +- .../lib/api/contracts/knowledge/connectors.ts | 3 +- .../lib/api/contracts/v1/knowledge/index.ts | 17 +- apps/sim/lib/billing/storage/index.ts | 1 + apps/sim/lib/billing/storage/limits.ts | 15 + apps/sim/lib/billing/storage/tracking.ts | 5 +- .../server/knowledge/knowledge-base.test.ts | 193 +++-- .../tools/server/knowledge/knowledge-base.ts | 446 ++++++----- apps/sim/lib/core/orchestration/types.ts | 26 + apps/sim/lib/knowledge/constants.ts | 14 + apps/sim/lib/knowledge/documents/service.ts | 20 +- apps/sim/lib/knowledge/folders.test.ts | 2 +- .../orchestration/connectors.test.ts | 298 +++++++ .../lib/knowledge/orchestration/connectors.ts | 735 ++++++++++++++++++ .../knowledge/orchestration/documents.test.ts | 329 ++++++++ .../lib/knowledge/orchestration/documents.ts | 486 ++++++++++++ apps/sim/lib/knowledge/orchestration/index.ts | 122 +-- .../orchestration/knowledge-bases.test.ts | 256 ++++++ .../orchestration/knowledge-bases.ts | 236 ++++++ .../knowledge/orchestration/restore.test.ts | 91 +++ .../lib/knowledge/orchestration/restore.ts | 88 +++ .../sim/lib/knowledge/orchestration/shared.ts | 84 ++ apps/sim/lib/knowledge/service.test.ts | 4 +- apps/sim/lib/knowledge/service.ts | 51 +- .../orchestration/restore-resource.ts | 5 +- 48 files changed, 3873 insertions(+), 1931 deletions(-) create mode 100644 apps/sim/lib/knowledge/orchestration/connectors.test.ts create mode 100644 apps/sim/lib/knowledge/orchestration/connectors.ts create mode 100644 apps/sim/lib/knowledge/orchestration/documents.test.ts create mode 100644 apps/sim/lib/knowledge/orchestration/documents.ts create mode 100644 apps/sim/lib/knowledge/orchestration/knowledge-bases.test.ts create mode 100644 apps/sim/lib/knowledge/orchestration/knowledge-bases.ts create mode 100644 apps/sim/lib/knowledge/orchestration/restore.test.ts create mode 100644 apps/sim/lib/knowledge/orchestration/restore.ts create mode 100644 apps/sim/lib/knowledge/orchestration/shared.ts diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index eeac6c843b5..806bbbccd83 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -709,9 +709,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "413": { "description": "The uploaded file exceeds the 100 MB limit, or the workspace storage limit has been reached.", "content": { 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]/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/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/v2/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts index 41560fb7558..ef9318b1dc6 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { document, knowledgeConnector } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -13,12 +12,18 @@ import { import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { deleteDocument } from '@/lib/knowledge/documents/service' +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, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' const logger = createLogger('V2KnowledgeDocumentDetailAPI') @@ -193,19 +198,21 @@ export const DELETE = withRouteHandler( const doc = docs[0] if (!doc) return v2Error('NOT_FOUND', 'Document not found') - await deleteDocument(documentId, requestId) - - recordAudit({ - workspaceId: parsed.data.query.workspaceId, - actorId: userId, - action: AuditAction.DOCUMENT_DELETED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: documentId, - resourceName: doc.filename, - description: `Deleted document "${doc.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: 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) { diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts index 60103508d3d..1f513d5f2ed 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -1,4 +1,3 @@ -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' @@ -20,13 +19,9 @@ import { 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 type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { validateFileType } from '@/lib/uploads/utils/validation' @@ -39,6 +34,7 @@ import { v2CursorList, v2Data, v2Error, + v2ErrorForOrchestration, v2RateLimitError, v2ValidationError, } from '@/app/api/v2/lib/response' @@ -248,47 +244,26 @@ 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, - billingAttribution.actorUserId - ) - - const documentData: DocumentData = { - documentId: newDocument.id, - filename: file.name, - fileUrl: uploadedFile.url, - fileSize: file.size, - mimeType: contentType, - } - - processDocumentsWithQueue( - [documentData], - knowledgeBaseId, - {}, + startProcessing: 'queue', + billingAttribution, + uploadedBy: billingAttribution.actorUserId, + userId, + source: 'api', requestId, - billingAttribution - ).catch(() => { - // Processing errors are logged internally by the queue. - }) - - 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 v2ErrorForOrchestration(outcome.errorCode, outcome.error) + } + const newDocument = outcome.document const document: V2KnowledgeDocumentSummary = { id: newDocument.id, @@ -310,18 +285,6 @@ export const POST = withRouteHandler( return v2Error('PAYLOAD_TOO_LARGE', error.message) } - if (error instanceof Error) { - if ( - error.message.includes('Storage limit exceeded') || - error.message.includes('storage limit') - ) { - return v2Error('PAYLOAD_TOO_LARGE', 'Storage limit exceeded') - } - if (error.message.includes('already exists')) { - return v2Error('CONFLICT', 'Resource already exists') - } - } - logger.error(`[${requestId}] Error uploading document`, { error: getErrorMessage(error, 'Unknown error'), }) diff --git a/apps/sim/app/api/v2/knowledge/[id]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/route.ts index e6ae3849bae..65364a2b808 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/route.ts @@ -1,4 +1,3 @@ -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' @@ -7,15 +6,24 @@ import { v2GetKnowledgeBaseContract, v2UpdateKnowledgeBaseContract, } from '@/lib/api/contracts/v2/knowledge' -import { isZodError, parseRequest } from '@/lib/api/server' +import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { deleteKnowledgeBase, updateKnowledgeBase } from '@/lib/knowledge/service' +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 { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' const logger = createLogger('V2KnowledgeDetailAPI') @@ -110,42 +118,21 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: Knowle const result = await resolveKnowledgeBaseScoped(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, }) - - return v2Data({ knowledgeBase: formatKnowledgeBase(updatedKb) }, { rateLimit }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - if (error instanceof Error) { - if (error.message.includes('does not have permission')) { - return v2Error('FORBIDDEN', 'Access denied') - } - if (error.message.includes('already exists')) { - return v2Error('CONFLICT', 'Resource already exists') - } + if (!outcome.success) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error) } + return v2Data({ knowledgeBase: formatKnowledgeBase(outcome.knowledgeBase) }, { rateLimit }) + } catch (error) { logger.error(`[${requestId}] Error updating knowledge base`, { error: getErrorMessage(error, 'Unknown error'), }) @@ -182,18 +169,16 @@ 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 v2ErrorForOrchestration(outcome.errorCode, outcome.error) + } return v2Data({ id, deleted: true as const }, { rateLimit }) } catch (error) { diff --git a/apps/sim/app/api/v2/knowledge/route.ts b/apps/sim/app/api/v2/knowledge/route.ts index 65aae6b8d5a..64e6445687b 100644 --- a/apps/sim/app/api/v2/knowledge/route.ts +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' @@ -6,11 +5,11 @@ import { v2CreateKnowledgeBaseContract, v2ListKnowledgeBasesContract, } from '@/lib/api/contracts/v2/knowledge' -import { isZodError, parseRequest } from '@/lib/api/server' +import { parseRequest } from '@/lib/api/server' 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 } from '@/lib/knowledge/service' +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 { v2ApiGateError } from '@/app/api/v2/lib/gate' @@ -18,6 +17,7 @@ import { v2CursorList, v2Data, v2Error, + v2ErrorForOrchestration, v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, @@ -97,50 +97,25 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) - 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, }) - - return v2Data({ knowledgeBase: formatKnowledgeBase(kb) }, { rateLimit, status: 201 }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - if (error instanceof Error) { - if (error.message.includes('does not have permission')) { - return v2Error('FORBIDDEN', 'Access denied') - } - if ( - error.message.includes('Storage limit exceeded') || - error.message.includes('storage limit') - ) { - return v2Error('PAYLOAD_TOO_LARGE', 'Storage limit exceeded') - } - if (error.message.includes('already exists')) { - return v2Error('CONFLICT', 'Resource already exists') - } + if (!outcome.success) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error) } + return v2Data( + { knowledgeBase: formatKnowledgeBase(outcome.knowledgeBase) }, + { rateLimit, status: 201 } + ) + } catch (error) { logger.error(`[${requestId}] Error creating knowledge base`, { error: getErrorMessage(error, 'Unknown error'), }) diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index 45ed1e6fcb3..3bdc2b90b91 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -159,10 +159,12 @@ export function decodeCursor>(cursor: string): T | n 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', } diff --git a/apps/sim/lib/api/contracts/knowledge/base.ts b/apps/sim/lib/api/contracts/knowledge/base.ts index 701073e3a23..2c142049afc 100644 --- a/apps/sim/lib/api/contracts/knowledge/base.ts +++ b/apps/sim/lib/api/contracts/knowledge/base.ts @@ -7,7 +7,10 @@ import { } from '@/lib/api/contracts/knowledge/shared' import { defineRouteContract } from '@/lib/api/contracts/types' import type { StrategyOptions } from '@/lib/chunkers/types' -import { KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH } from '@/lib/knowledge/constants' +import { + DEFAULT_CHUNKING_CONFIG, + KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH, +} from '@/lib/knowledge/constants' export const knowledgeScopeSchema = z.enum(['active', 'archived', 'all']) export type KnowledgeScope = z.output @@ -67,11 +70,7 @@ export const createKnowledgeBaseBodySchema = z.object({ folderId: z.string().min(1, 'Folder ID cannot be empty').nullable().optional(), embeddingModel: z.literal('text-embedding-3-small').default('text-embedding-3-small'), embeddingDimension: z.literal(1536).default(1536), - chunkingConfig: chunkingConfigSchema.default({ - maxSize: 1024, - minSize: 100, - overlap: 200, - }), + chunkingConfig: chunkingConfigSchema.default(DEFAULT_CHUNKING_CONFIG), }) export const updateKnowledgeBaseBodySchema = createKnowledgeBaseBodySchema diff --git a/apps/sim/lib/api/contracts/knowledge/connectors.ts b/apps/sim/lib/api/contracts/knowledge/connectors.ts index ed147c741a1..cef3d8718d1 100644 --- a/apps/sim/lib/api/contracts/knowledge/connectors.ts +++ b/apps/sim/lib/api/contracts/knowledge/connectors.ts @@ -22,7 +22,8 @@ export const updateConnectorBodySchema = z.object({ }) export const deleteConnectorQuerySchema = z.object({ - deleteDocuments: z.boolean().optional(), + /** Also hard-delete the documents the connector produced; kept by default. */ + deleteDocuments: booleanQueryFlagSchema.optional().default(false), }) export const connectorDocumentsQuerySchema = z.object({ diff --git a/apps/sim/lib/api/contracts/v1/knowledge/index.ts b/apps/sim/lib/api/contracts/v1/knowledge/index.ts index 76abfcf16a0..926786090b4 100644 --- a/apps/sim/lib/api/contracts/v1/knowledge/index.ts +++ b/apps/sim/lib/api/contracts/v1/knowledge/index.ts @@ -7,7 +7,10 @@ import { } from '@/lib/api/contracts/knowledge/shared' import { requiredFieldSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' -import { KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH } from '@/lib/knowledge/constants' +import { + DEFAULT_CHUNKING_CONFIG, + KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH, +} from '@/lib/knowledge/constants' /** * Public API v1 schemas (`/api/v1/knowledge/**`) @@ -25,9 +28,9 @@ import { KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH } from '@/lib/knowledge/constants /** Simpler chunking config used by the public API (no `strategy`). */ export const v1ChunkingConfigSchema = z.object({ - maxSize: z.number().min(100).max(4000).default(1024), - minSize: z.number().min(1).max(2000).default(100), - overlap: z.number().min(0).max(500).default(200), + maxSize: z.number().min(100).max(4000).default(DEFAULT_CHUNKING_CONFIG.maxSize), + minSize: z.number().min(1).max(2000).default(DEFAULT_CHUNKING_CONFIG.minSize), + overlap: z.number().min(0).max(500).default(DEFAULT_CHUNKING_CONFIG.overlap), }) /** GET `/api/v1/knowledge` — list knowledge bases scoped to a workspace. */ @@ -46,11 +49,7 @@ export const v1CreateKnowledgeBaseBodySchema = z.object({ `Description must be ${KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH} characters or less` ) .optional(), - chunkingConfig: v1ChunkingConfigSchema.optional().default({ - maxSize: 1024, - minSize: 100, - overlap: 200, - }), + chunkingConfig: v1ChunkingConfigSchema.optional().default(DEFAULT_CHUNKING_CONFIG), }) /** GET/DELETE `/api/v1/knowledge/[id]` — workspace scope param. */ diff --git a/apps/sim/lib/billing/storage/index.ts b/apps/sim/lib/billing/storage/index.ts index 5496ad64a5d..59e829499df 100644 --- a/apps/sim/lib/billing/storage/index.ts +++ b/apps/sim/lib/billing/storage/index.ts @@ -6,6 +6,7 @@ export { getStorageUsageForBillingContext, getUserStorageLimit, getUserStorageUsage, + StorageLimitExceededError, } from './limits' export { applyStorageUsageDeltasInTx, diff --git a/apps/sim/lib/billing/storage/limits.ts b/apps/sim/lib/billing/storage/limits.ts index 34b01d97b07..96d27a82265 100644 --- a/apps/sim/lib/billing/storage/limits.ts +++ b/apps/sim/lib/billing/storage/limits.ts @@ -21,9 +21,24 @@ import type { StorageBillingContext } from '@/lib/billing/storage/context' import { getLegacyStorageBillingEntity } from '@/lib/billing/storage/entity' import { getEnv } from '@/lib/core/config/env' import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' const logger = createLogger('StorageLimits') +/** + * Thrown when accepting a write would push its payer past its storage quota. + * + * An {@link OrchestrationError} so every surface reaches 413 by class. The bare + * `Error` this replaced was classified by searching the message for "storage + * limit", which the UI, v1, and v2 knowledge routes each re-implemented. + */ +export class StorageLimitExceededError extends OrchestrationError { + constructor(message: string) { + super('payload_too_large', message) + this.name = 'StorageLimitExceededError' + } +} + type StorageLimits = ReturnType interface StorageLimitResolutionInput { diff --git a/apps/sim/lib/billing/storage/tracking.ts b/apps/sim/lib/billing/storage/tracking.ts index 1fab48fa652..0de9b03507b 100644 --- a/apps/sim/lib/billing/storage/tracking.ts +++ b/apps/sim/lib/billing/storage/tracking.ts @@ -18,6 +18,7 @@ import { getUserStorageLimit, getUserStorageUsage, isStorageEnforcementEnabled, + StorageLimitExceededError, } from '@/lib/billing/storage/limits' import { getFreeTierLimit, isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' import type { DbOrTx } from '@/lib/db/types' @@ -362,7 +363,7 @@ export async function applyStorageUsageDeltasInTx( payerDelta.maximumUsage !== undefined && nextUsage > payerDelta.maximumUsage ) { - throw new Error( + throw new StorageLimitExceededError( `Storage limit exceeded. Used: ${(nextUsage / 1024 ** 3).toFixed(2)}GB, Limit: ${(payerDelta.maximumUsage / 1024 ** 3).toFixed(0)}GB` ) } @@ -471,7 +472,7 @@ async function mutateWorkspaceStorageUsage( currentPayerUsage + bytes > maximumUsage ) { const newUsage = currentPayerUsage + bytes - throw new Error( + throw new StorageLimitExceededError( `Storage limit exceeded. Used: ${(newUsage / 1024 ** 3).toFixed(2)}GB, Limit: ${(maximumUsage / 1024 ** 3).toFixed(0)}GB` ) } diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts index 186e785d29c..20980bc6a95 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts @@ -2,34 +2,33 @@ * @vitest-environment node */ import { knowledgeConnector } from '@sim/db/schema' -import { queueTableRows, resetDbChainMock, resetUrlsMock, urlsMockFns } from '@sim/testing' -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockAssertBillingAttributionSnapshot, mockCheckKnowledgeBaseWriteAccess, - mockFetch, - mockGenerateInternalToken, - mockSerializeBillingAttributionHeader, + mockGetKnowledgeBaseById, + mockPerformCreateKnowledgeConnector, + mockPerformDeleteKnowledgeBase, + mockPerformDeleteKnowledgeConnector, + mockPerformSyncKnowledgeConnector, } = vi.hoisted(() => ({ mockAssertBillingAttributionSnapshot: vi.fn(), mockCheckKnowledgeBaseWriteAccess: vi.fn(), - mockFetch: vi.fn(), - mockGenerateInternalToken: vi.fn(), - mockSerializeBillingAttributionHeader: vi.fn(), + mockGetKnowledgeBaseById: vi.fn(), + mockPerformCreateKnowledgeConnector: vi.fn(), + mockPerformDeleteKnowledgeBase: vi.fn(), + mockPerformDeleteKnowledgeConnector: vi.fn(), + mockPerformSyncKnowledgeConnector: vi.fn(), })) -vi.mock('@/lib/auth/internal', () => ({ - generateInternalToken: mockGenerateInternalToken, -})) vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ checkActorUsageLimits: vi.fn(), })) vi.mock('@/lib/billing/core/billing-attribution', () => ({ - BILLING_ATTRIBUTION_HEADER: 'x-sim-billing-attribution', assertBillingAttributionSnapshot: mockAssertBillingAttributionSnapshot, checkAttributedUsageLimits: vi.fn(), - serializeBillingAttributionHeader: mockSerializeBillingAttributionHeader, })) vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({ KnowledgeBase: { id: 'knowledge_base' }, @@ -37,28 +36,24 @@ vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({ vi.mock('@/lib/copilot/tools/server/base-tool', () => ({ assertServerToolNotAborted: vi.fn(), })) -beforeAll(() => { - urlsMockFns.mockGetInternalApiBaseUrl.mockReturnValue('http://internal.test') -}) - -afterAll(resetUrlsMock) -vi.mock('@/lib/knowledge/documents/service', () => ({ - createSingleDocument: vi.fn(), - deleteDocument: vi.fn(), - processDocumentAsync: vi.fn(), - updateDocument: vi.fn(), -})) vi.mock('@/lib/knowledge/embeddings', () => ({ - EMBEDDING_DIMENSIONS: 1536, generateSearchEmbedding: vi.fn(), - getConfiguredEmbeddingModel: vi.fn(), recordSearchEmbeddingUsage: vi.fn(), })) +vi.mock('@/lib/knowledge/orchestration', () => ({ + performCreateKnowledgeBase: vi.fn(), + performDeleteKnowledgeBase: mockPerformDeleteKnowledgeBase, + performCreateKnowledgeConnector: mockPerformCreateKnowledgeConnector, + performDeleteKnowledgeConnector: mockPerformDeleteKnowledgeConnector, + performDeleteKnowledgeDocument: vi.fn(), + performSyncKnowledgeConnector: mockPerformSyncKnowledgeConnector, + performUpdateKnowledgeBase: vi.fn(), + performUpdateKnowledgeConnector: vi.fn(), + performUpdateKnowledgeDocument: vi.fn(), + performUploadKnowledgeDocument: vi.fn(), +})) vi.mock('@/lib/knowledge/service', () => ({ - createKnowledgeBase: vi.fn(), - deleteKnowledgeBase: vi.fn(), - getKnowledgeBaseById: vi.fn(), - updateKnowledgeBase: vi.fn(), + getKnowledgeBaseById: mockGetKnowledgeBaseById, })) vi.mock('@/lib/knowledge/tags/service', () => ({ createTagDefinition: vi.fn(), @@ -73,6 +68,7 @@ vi.mock('@/lib/uploads', () => ({ StorageService: {} })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ resolveWorkspaceFileReference: vi.fn(), })) +vi.mock('@/app/api/auth/oauth/utils', () => ({ getCredential: vi.fn() })) vi.mock('@/app/api/knowledge/search/utils', () => ({ executeKnowledgeSearch: vi.fn(), })) @@ -97,6 +93,12 @@ const BILLING_ATTRIBUTION = { payerSubscription: null, } +const CONTEXT = { + userId: 'external-admin', + workspaceId: 'workspace-paid', + billingAttribution: BILLING_ATTRIBUTION, +} + describe('knowledge base connector Copilot operations', () => { afterAll(() => { resetDbChainMock() @@ -105,11 +107,8 @@ describe('knowledge base connector Copilot operations', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - vi.stubGlobal('fetch', mockFetch) queueTableRows(knowledgeConnector, [{ knowledgeBaseId: 'knowledge-base-1' }]) mockAssertBillingAttributionSnapshot.mockReturnValue(BILLING_ATTRIBUTION) - mockSerializeBillingAttributionHeader.mockReturnValue('serialized-attribution') - mockGenerateInternalToken.mockResolvedValue('internal-token') mockCheckKnowledgeBaseWriteAccess.mockResolvedValue({ hasAccess: true, knowledgeBase: { @@ -118,21 +117,21 @@ describe('knowledge base connector Copilot operations', () => { name: 'Paid KB', }, }) - mockFetch.mockResolvedValue({ - ok: true, - json: vi.fn().mockResolvedValue({ - success: true, - data: { - id: 'connector-1', - connectorType: 'notion', - status: 'active', - }, - }), + mockPerformCreateKnowledgeConnector.mockResolvedValue({ + success: true, + connector: { id: 'connector-1', connectorType: 'notion', status: 'active' }, + }) + mockPerformSyncKnowledgeConnector.mockResolvedValue({ success: true }) + mockPerformDeleteKnowledgeConnector.mockResolvedValue({ + success: true, + documentsDeleted: 0, + documentsKept: 3, }) }) it.each([ { + operation: 'add_connector', params: { operation: 'add_connector', args: { @@ -141,35 +140,87 @@ describe('knowledge base connector Copilot operations', () => { apiKey: 'api-key', }, }, - expectedPath: '/api/knowledge/knowledge-base-1/connectors', + perform: mockPerformCreateKnowledgeConnector, }, { - params: { - operation: 'sync_connector', - args: { connectorId: 'connector-1' }, - }, - expectedPath: '/api/knowledge/knowledge-base-1/connectors/connector-1/sync', + operation: 'sync_connector', + params: { operation: 'sync_connector', args: { connectorId: 'connector-1' } }, + perform: mockPerformSyncKnowledgeConnector, }, - ])( - 'forwards immutable billing attribution for $params.operation', - async ({ params, expectedPath }) => { - const result = await knowledgeBaseServerTool.execute(params, { - userId: 'external-admin', - workspaceId: 'workspace-paid', - billingAttribution: BILLING_ATTRIBUTION, - }) - - expect(result.success).toBe(true) - expect(mockFetch).toHaveBeenCalledWith( - `http://internal.test${expectedPath}`, - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer internal-token', - 'x-sim-billing-attribution': 'serialized-attribution', - }), - }) - ) - expect(mockSerializeBillingAttributionHeader).toHaveBeenCalledWith(BILLING_ATTRIBUTION) - } - ) + ])('forwards immutable billing attribution for $operation', async ({ params, perform }) => { + const result = await knowledgeBaseServerTool.execute(params, CONTEXT) + + expect(result.success).toBe(true) + // The operation runs in-process now. The payer travels as a value on the + // orchestration call rather than as a serialized header on an internal + // HTTP self-call back into this same process. + const call = perform.mock.calls[0][0] + expect(await call.resolveBillingAttribution()).toEqual(BILLING_ATTRIBUTION) + expect(call.source).toBe('agent') + expect(mockAssertBillingAttributionSnapshot).toHaveBeenCalledWith(BILLING_ATTRIBUTION) + }) + + it('reports a failed knowledge base delete as failed, not as missing', async () => { + mockGetKnowledgeBaseById.mockResolvedValue({ + id: 'knowledge-base-1', + name: 'Paid KB', + workspaceId: 'workspace-paid', + }) + mockPerformDeleteKnowledgeBase.mockResolvedValue({ + success: false, + error: 'Knowledge base is locked', + errorCode: 'conflict', + }) + + const result = await knowledgeBaseServerTool.execute( + { operation: 'delete', args: { knowledgeBaseId: 'knowledge-base-1' } }, + CONTEXT + ) + + // A knowledge base that exists but could not be archived is neither deleted + // nor missing — folding it into notFound told the user it was never there. + expect(result.data.notFound).toEqual([]) + expect(result.data.failed).toEqual([ + { id: 'knowledge-base-1', name: 'Paid KB', reason: 'Knowledge base is locked' }, + ]) + expect(result.message).toContain('Knowledge base is locked') + }) + + it('never relays an unclassified fault to the agent verbatim', async () => { + mockGetKnowledgeBaseById.mockResolvedValue({ + id: 'knowledge-base-1', + name: 'Paid KB', + workspaceId: 'workspace-paid', + }) + mockPerformDeleteKnowledgeBase.mockResolvedValue({ + success: false, + error: 'select "id" from "knowledge_base" — connection terminated', + errorCode: 'internal', + }) + + const result = await knowledgeBaseServerTool.execute( + { operation: 'delete', args: { knowledgeBaseId: 'knowledge-base-1' } }, + CONTEXT + ) + + expect(result.data.failed[0].reason).toBe('Failed to delete knowledge base') + expect(result.message).not.toContain('connection terminated') + }) + + it('reports that a deleted connector kept its documents, because it did', async () => { + const result = await knowledgeBaseServerTool.execute( + { operation: 'delete_connector', args: { connectorId: 'connector-1' } }, + CONTEXT + ) + + // The old wording claimed the documents "have been removed". They never + // were: the tool reached the route over HTTP with no query string, so the + // route's keep-documents default always applied. + expect(result.success).toBe(true) + expect(result.message).toContain('3 document(s) were kept') + expect(result.message).not.toContain('removed') + expect(mockPerformDeleteKnowledgeConnector).toHaveBeenCalledWith( + expect.objectContaining({ connectorId: 'connector-1', source: 'agent' }) + ) + }) }) diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index bf19100c9ec..a7f41a21768 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -1,18 +1,16 @@ import { db } from '@sim/db' import { knowledgeConnector } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getErrorMessage, toError } from '@sim/utils/errors' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { filterUndefined } from '@sim/utils/object' import { truncate } from '@sim/utils/string' import { and, eq, isNull } from 'drizzle-orm' -import { generateInternalToken } from '@/lib/auth/internal' import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { assertBillingAttributionSnapshot, - BILLING_ATTRIBUTION_HEADER, type BillingAttributionSnapshot, checkAttributedUsageLimits, - serializeBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' import { KnowledgeBase } from '@/lib/copilot/generated/tool-catalog-v1' import { @@ -20,25 +18,24 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { - createSingleDocument, - deleteDocument, - processDocumentAsync, - updateDocument, -} from '@/lib/knowledge/documents/service' + messageForOrchestrationError, + type OrchestrationErrorCode, +} from '@/lib/core/orchestration/types' +import { generateSearchEmbedding, recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' import { - EMBEDDING_DIMENSIONS, - generateSearchEmbedding, - getConfiguredEmbeddingModel, - recordSearchEmbeddingUsage, -} from '@/lib/knowledge/embeddings' -import { - createKnowledgeBase, - deleteKnowledgeBase, - getKnowledgeBaseById, - updateKnowledgeBase, -} from '@/lib/knowledge/service' + performCreateKnowledgeBase, + performCreateKnowledgeConnector, + performDeleteKnowledgeBase, + performDeleteKnowledgeConnector, + performDeleteKnowledgeDocument, + performSyncKnowledgeConnector, + performUpdateKnowledgeBase, + performUpdateKnowledgeConnector, + performUpdateKnowledgeDocument, + performUploadKnowledgeDocument, +} from '@/lib/knowledge/orchestration' +import { getKnowledgeBaseById } from '@/lib/knowledge/service' import { createTagDefinition, deleteTagDefinition, @@ -50,6 +47,7 @@ import { } from '@/lib/knowledge/tags/service' import { StorageService } from '@/lib/uploads' import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { getCredential } from '@/app/api/auth/oauth/utils' import { executeKnowledgeSearch } from '@/app/api/knowledge/search/utils' import { checkDocumentWriteAccess, @@ -73,6 +71,20 @@ function requireKnowledgeBillingAttribution( return attribution } +/** + * The message the agent — and therefore the user — is shown for a failed + * operation. Mirrors `messageForOrchestrationError` on the HTTP surfaces: a + * classified failure is caller-fixable and safe to relay, an unclassified one + * carries whatever text the fault happened to have (a driver's failed SQL, say) + * and is replaced by the operation's own wording. + */ +function agentFacingError( + outcome: { error?: string; errorCode?: OrchestrationErrorCode }, + fallback: string +): string { + return messageForOrchestrationError(outcome, fallback) +} + type KnowledgeBaseArgs = { operation: string args?: Record @@ -109,6 +121,17 @@ export const knowledgeBaseServerTool: BaseServerTool ({ + userId: context.userId as string, + source: 'agent' as const, + requestId, + }) try { switch (operation) { @@ -129,29 +152,21 @@ export const knowledgeBaseServerTool: BaseServerTool { - logger.error('Background document processing failed', { - documentId: doc.id, - error: toError(err).message, - }) + startProcessing: 'async', + billingAttribution, }) + if (!outcome.success) { + failedFiles.push(fileRef) + continue + } - added.push({ documentId: doc.id, filename: fileRecord.name }) - - logger.info('Workspace file added to knowledge base via copilot', { - knowledgeBaseId: args.knowledgeBaseId, - documentId: doc.id, - fileName: fileRecord.name, - userId: context.userId, - }) + added.push({ documentId: outcome.document.id, filename: fileRecord.name }) } const addedNames = added.map((a) => a.filename).join(', ') @@ -461,13 +460,20 @@ export const knowledgeBaseServerTool: BaseServerTool = [] const notFound: string[] = [] + // A knowledge base that exists but could not be archived is neither + // deleted nor missing. Folding it into `notFound` told the user it was + // never there instead of why the delete failed. + const failed: Array<{ id: string; name: string; reason: string }> = [] for (const kbId of kbIds) { const writeAccess = await checkKnowledgeBaseWriteAccess(kbId, context.userId) @@ -510,23 +520,42 @@ export const knowledgeBaseServerTool: BaseServerTool 0 ? `Deleted: ${deleted.map((d) => d.name).join(', ')}` : null, + failed.length > 0 + ? `Failed: ${failed.map((f) => `${f.name} (${f.reason})`).join(', ')}` + : null, + ] + .filter(Boolean) + .join('. ') + return { success: deleted.length > 0, - message: - deleted.length > 0 - ? `Deleted: ${deleted.map((d) => d.name).join(', ')}` - : 'No knowledge bases found', - data: { deleted, notFound }, + message: deleteSummary || 'No knowledge bases found', + data: { deleted, notFound, failed }, } } @@ -557,8 +586,16 @@ export const knowledgeBaseServerTool: BaseServerTool = { - connectorType: args.connectorType, - sourceConfig: args.sourceConfig ?? {}, - syncIntervalMinutes: args.syncIntervalMinutes ?? 1440, - } - - if (args.credentialId) { - createBody.credentialId = args.credentialId - } - if (args.apiKey) { - createBody.apiKey = args.apiKey - } - + const sourceConfig: Record = { ...(args.sourceConfig ?? {}) } if (args.disabledTagIds?.length) { - ;(createBody.sourceConfig as Record).disabledTagIds = - args.disabledTagIds + sourceConfig.disabledTagIds = args.disabledTagIds } + const requestId = generateId().slice(0, 8) assertNotAborted() - const createRes = await connectorApiCall( - context.userId, - `/api/knowledge/${args.knowledgeBaseId}/connectors`, - 'POST', - createBody, - billingAttribution - ) - - if (!createRes.success) { - return { success: false, message: createRes.error ?? 'Failed to create connector' } - } - - const connector = createRes.data - logger.info('Connector created via copilot', { - connectorId: connector.id, + const outcome = await performCreateKnowledgeConnector({ + ...actor(requestId), + knowledgeBase: { + id: args.knowledgeBaseId, + name: writeAccess.knowledgeBase.name, + workspaceId: connectorWorkspaceId, + }, connectorType: args.connectorType, - knowledgeBaseId: args.knowledgeBaseId, - userId: context.userId, + credentialId: args.credentialId, + apiKey: args.apiKey, + sourceConfig, + syncIntervalMinutes: args.syncIntervalMinutes ?? 1440, + resolveBillingAttribution: async () => billingAttribution, + resolveAccessToken: async (credentialId) => + (await getCredential(requestId, credentialId, context.userId as string)) + ?.accessToken ?? null, }) + if (!outcome.success) { + return { success: false, message: agentFacingError(outcome, 'Failed to add connector') } + } + const connector = outcome.connector return { success: true, message: `Connector "${args.connectorType}" added to knowledge base. Initial sync started.`, data: { id: connector.id, - connectorType: connector.connectorType ?? connector.connector_type, + connectorType: connector.connectorType, status: connector.status, knowledgeBaseId: args.knowledgeBaseId, }, @@ -962,41 +1005,38 @@ export const knowledgeBaseServerTool: BaseServerTool = {} - if (args.sourceConfig !== undefined) updateBody.sourceConfig = args.sourceConfig - if (args.syncIntervalMinutes !== undefined) - updateBody.syncIntervalMinutes = args.syncIntervalMinutes - if (args.connectorStatus !== undefined) updateBody.status = args.connectorStatus - - if (Object.keys(updateBody).length === 0) { - return { - success: false, - message: - 'At least one of sourceConfig, syncIntervalMinutes, or connectorStatus is required', - } + const updates = { + sourceConfig: args.sourceConfig, + syncIntervalMinutes: args.syncIntervalMinutes, + status: args.connectorStatus, } + const requestId = generateId().slice(0, 8) assertNotAborted() - const updateRes = await connectorApiCall( - context.userId, - `/api/knowledge/${kbId}/connectors/${args.connectorId}`, - 'PATCH', - updateBody - ) - - if (!updateRes.success) { - return { success: false, message: updateRes.error ?? 'Failed to update connector' } - } - - logger.info('Connector updated via copilot', { + // No `validateSourceConfig`: the agent has no requesting identity to + // resolve the connector's OAuth token with, so a replacement config is + // stored unvalidated and the next sync reports any problem with it. + const outcome = await performUpdateKnowledgeConnector({ + ...actor(requestId), + knowledgeBase: { + id: kbId, + name: writeAccess.knowledgeBase.name, + workspaceId: writeAccess.knowledgeBase.workspaceId ?? null, + }, connectorId: args.connectorId, - userId: context.userId, + updates, }) + if (!outcome.success) { + return { + success: false, + message: agentFacingError(outcome, 'Failed to update connector'), + } + } return { success: true, message: 'Connector updated successfully', - data: { id: args.connectorId, ...updateBody }, + data: { id: args.connectorId, ...filterUndefined(updates) }, } } @@ -1015,26 +1055,39 @@ export const knowledgeBaseServerTool: BaseServerTool 0 + ? `Connector deleted successfully. Its ${outcome.documentsKept} document(s) were kept in the knowledge base.` + : 'Connector deleted successfully.', + data: { + id: args.connectorId, + documentsKept: outcome.documentsKept, + documentsDeleted: outcome.documentsDeleted, + }, } } @@ -1064,23 +1117,24 @@ export const knowledgeBaseServerTool: BaseServerTool billingAttribution, }) + if (!outcome.success) { + return { + success: false, + message: agentFacingError(outcome, 'Failed to sync connector'), + } + } return { success: true, @@ -1111,42 +1165,6 @@ export const knowledgeBaseServerTool: BaseServerTool, - billingAttribution?: BillingAttributionSnapshot -): Promise<{ success: boolean; data?: any; error?: string }> { - const token = await generateInternalToken(userId) - const baseUrl = getInternalApiBaseUrl() - - const res = await fetch(`${baseUrl}${path}`, { - method, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - ...(billingAttribution - ? { - [BILLING_ATTRIBUTION_HEADER]: serializeBillingAttributionHeader(billingAttribution), - } - : {}), - }, - ...(body ? { body: JSON.stringify(body) } : {}), - }) - - const json = await res.json().catch(() => ({})) - - if (!res.ok) { - return { - success: false, - error: json.error || `API returned ${res.status}`, - } - } - - return { success: true, data: json.data } -} - async function resolveKnowledgeBaseId(connectorId: string): Promise { const rows = await db .select({ knowledgeBaseId: knowledgeConnector.knowledgeBaseId }) diff --git a/apps/sim/lib/core/orchestration/types.ts b/apps/sim/lib/core/orchestration/types.ts index 59ccf54aad6..eb42af6bf65 100644 --- a/apps/sim/lib/core/orchestration/types.ts +++ b/apps/sim/lib/core/orchestration/types.ts @@ -1,9 +1,16 @@ export type OrchestrationErrorCode = | 'validation' + /** + * The credentials this operation depends on are no longer usable — a stored + * third-party token that will not refresh, not an unauthenticated caller. + * Distinct from `forbidden`, which is the caller lacking permission. + */ + | 'unauthorized' | 'not_found' | 'forbidden' | 'conflict' | 'locked' + | 'payload_too_large' | 'internal' /** @@ -13,13 +20,32 @@ export type OrchestrationErrorCode = */ export function statusForOrchestrationError(code: OrchestrationErrorCode | undefined): number { if (code === 'validation') return 400 + if (code === 'unauthorized') return 401 if (code === 'forbidden') return 403 if (code === 'not_found') return 404 if (code === 'conflict') return 409 if (code === 'locked') return 423 + if (code === 'payload_too_large') return 413 return 500 } +/** + * The message a JSON route should render for an orchestration failure. + * + * A classified failure is caller-fixable, so its message is written for the + * caller and is safe to return. An unclassified one carries whatever text the + * fault happened to have — a driver's failed SQL, say — so the caller gets the + * route's own generic wording instead. `v2ErrorForOrchestration` applies the + * same rule for the v2 envelope. + */ +export function messageForOrchestrationError( + result: { error?: string; errorCode?: OrchestrationErrorCode }, + fallback: string +): string { + if (!result.errorCode || result.errorCode === 'internal') return fallback + return result.error ?? fallback +} + /** * A domain failure that already knows its own class. * diff --git a/apps/sim/lib/knowledge/constants.ts b/apps/sim/lib/knowledge/constants.ts index 84b9d8c9830..57ee50321be 100644 --- a/apps/sim/lib/knowledge/constants.ts +++ b/apps/sim/lib/knowledge/constants.ts @@ -1,6 +1,20 @@ /** Max character length for a knowledge base description, enforced at every layer (UI, internal API, v1 API). */ export const KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH = 10_000 +/** + * Chunking a knowledge base gets when its creator names no configuration. + * + * Applied in `lib/knowledge/orchestration` so the UI, the v1 and v2 APIs, and + * the copilot agent all index identical input identically. Previously each + * caller carried its own literal and the agent's `minSize` was 1, so the same + * document chunked differently depending on who uploaded it. + */ +export const DEFAULT_CHUNKING_CONFIG = { + maxSize: 1024, + minSize: 100, + overlap: 200, +} as const + export const TAG_SLOT_CONFIG = { text: { slots: ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const, diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 1b8651a8424..9ca64bb6d4b 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -32,6 +32,7 @@ import { maybeNotifyStorageLimitForBillingContext, resolveStorageBillingContext, type StorageBillingContext, + StorageLimitExceededError, } from '@/lib/billing/storage' import { checkAndBillOverageThreshold, @@ -41,6 +42,7 @@ import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types' import { resolveTriggerRegion } from '@/lib/core/async-jobs/region' import { env, envNumber } from '@/lib/core/config/env' import { getCostMultiplier, isTriggerDevEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import { processDocument } from '@/lib/knowledge/documents/document-processor' import { @@ -85,9 +87,9 @@ const logger = createLogger('DocumentService') * storage object that is not owned by the target knowledge base's workspace. * Routes map this to a 403. */ -export class KnowledgeBaseFileOwnershipError extends Error { +export class KnowledgeBaseFileOwnershipError extends OrchestrationError { constructor(public readonly storageKey: string) { - super('Document file is not owned by this knowledge base') + super('forbidden', 'Document file is not owned by this knowledge base') this.name = 'KnowledgeBaseFileOwnershipError' } } @@ -1052,7 +1054,7 @@ async function resolveDocumentStorageAdmission( .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) .limit(1) if (!kb) { - throw new Error('Knowledge base not found') + throw new OrchestrationError('not_found', 'Knowledge base not found') } if (bytes <= 0) { @@ -1064,7 +1066,7 @@ async function resolveDocumentStorageAdmission( const context = await resolveStorageBillingContext(kb.workspaceId) const quotaCheck = await checkStorageQuotaForBillingContext(context, bytes) if (!quotaCheck.allowed) { - throw new Error(quotaCheck.error || 'Storage limit exceeded') + throw new StorageLimitExceededError(quotaCheck.error || 'Storage limit exceeded') } return { workspaceId: kb.workspaceId, @@ -1078,7 +1080,7 @@ async function resolveDocumentStorageAdmission( getHighestPrioritySubscription(billedUserId), ]) if (!quotaCheck.allowed) { - throw new Error(quotaCheck.error || 'Storage limit exceeded') + throw new StorageLimitExceededError(quotaCheck.error || 'Storage limit exceeded') } return { workspaceId: null, @@ -1126,7 +1128,7 @@ export async function createDocumentRecords( .limit(1) if (kb.length === 0) { - throw new Error('Knowledge base not found') + throw new OrchestrationError('not_found', 'Knowledge base not found') } if ( @@ -1176,7 +1178,7 @@ export async function createDocumentRecords( preparedBilling.bytes ) if (!quotaCheck.allowed) { - throw new Error(quotaCheck.error || 'Storage limit exceeded') + throw new StorageLimitExceededError(quotaCheck.error || 'Storage limit exceeded') } } } @@ -1618,7 +1620,7 @@ export async function createSingleDocument( .limit(1) if (kb.length === 0) { - throw new Error('Knowledge base not found') + throw new OrchestrationError('not_found', 'Knowledge base not found') } if ( @@ -1665,7 +1667,7 @@ export async function createSingleDocument( preparedBilling.bytes ) if (!quotaCheck.allowed) { - throw new Error(quotaCheck.error || 'Storage limit exceeded') + throw new StorageLimitExceededError(quotaCheck.error || 'Storage limit exceeded') } } } diff --git a/apps/sim/lib/knowledge/folders.test.ts b/apps/sim/lib/knowledge/folders.test.ts index a81ebba83bf..009827d4a90 100644 --- a/apps/sim/lib/knowledge/folders.test.ts +++ b/apps/sim/lib/knowledge/folders.test.ts @@ -105,7 +105,7 @@ describe('createKnowledgeBase — folder assignment', () => { await expect( createKnowledgeBase({ ...CREATE_INPUT, folderId: 'f-1' }, 'req-1') - ).rejects.toMatchObject({ code: 'KNOWLEDGE_BASE_FORBIDDEN' }) + ).rejects.toMatchObject({ code: 'forbidden' }) expect(mockFindActiveFolder).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/knowledge/orchestration/connectors.test.ts b/apps/sim/lib/knowledge/orchestration/connectors.test.ts new file mode 100644 index 00000000000..68f7a10f242 --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/connectors.test.ts @@ -0,0 +1,298 @@ +/** + * @vitest-environment node + */ +import { document } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCaptureServerEvent, + mockDispatchSync, + mockHasWorkspaceLiveSyncAccess, + mockRecordAudit, +} = vi.hoisted(() => ({ + mockCaptureServerEvent: vi.fn(), + mockDispatchSync: vi.fn(), + mockHasWorkspaceLiveSyncAccess: vi.fn(), + mockRecordAudit: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + CONNECTOR_CREATED: 'connector.created', + CONNECTOR_UPDATED: 'connector.updated', + CONNECTOR_DELETED: 'connector.deleted', + CONNECTOR_SYNCED: 'connector.synced', + }, + AuditResourceType: { CONNECTOR: 'connector' }, + recordAudit: mockRecordAudit, +})) +vi.mock('@/lib/api-key/crypto', () => ({ encryptApiKey: vi.fn() })) +vi.mock('@/lib/billing/core/subscription', () => ({ + hasWorkspaceLiveSyncAccess: mockHasWorkspaceLiveSyncAccess, +})) +vi.mock('@/lib/knowledge/connectors/queue', () => ({ dispatchSync: mockDispatchSync })) +vi.mock('@/lib/knowledge/documents/service', () => ({ + deleteDocumentStorageFiles: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('@/lib/knowledge/tags/service', () => ({ + cleanupUnusedTagDefinitions: vi.fn().mockResolvedValue(undefined), + createTagDefinition: vi.fn(), +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent })) + +import { + performDeleteKnowledgeConnector, + performSyncKnowledgeConnector, + performUpdateKnowledgeConnector, +} from '@/lib/knowledge/orchestration/connectors' + +const KB = { id: 'kb-1', name: 'Docs', workspaceId: 'ws-1' } +const ACTOR = { userId: 'user-1', source: 'agent' as const, requestId: 'req-1' } +const BILLING = { actorUserId: 'user-1', workspaceId: 'ws-1' } as never +const resolveBillingAttribution = vi.fn().mockResolvedValue(BILLING) + +describe('performDeleteKnowledgeConnector', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + afterAll(resetDbChainMock) + + it('reports the documents it kept, so the caller cannot claim otherwise', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + queueTableRows(document, [ + { id: 'doc-1', fileUrl: '/a.txt' }, + { id: 'doc-2', fileUrl: '/b.txt' }, + ]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'conn-1' }]) + + const outcome = await performDeleteKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + }) + + // The default keeps the documents. The copilot tool used to assert they had + // been removed while taking exactly this path. + expect(outcome).toMatchObject({ success: true, documentsKept: 2, documentsDeleted: 0 }) + expect(dbChainMockFns.delete).not.toHaveBeenCalledWith(document) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ deleteDocuments: false, documentsKept: 2 }), + }) + ) + }) + + it('reports the documents it deleted when asked to delete them', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + queueTableRows(document, [{ id: 'doc-1', fileUrl: '/a.txt' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'conn-1' }]) + + const outcome = await performDeleteKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + deleteDocuments: true, + }) + + expect(outcome).toMatchObject({ success: true, documentsDeleted: 1, documentsKept: 0 }) + }) + + it('reports a missing connector as not found', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + const outcome = await performDeleteKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'not_found' }) + expect(mockRecordAudit).not.toHaveBeenCalled() + }) +}) + +describe('performUpdateKnowledgeConnector', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + afterAll(resetDbChainMock) + + it('rejects an update that names nothing before reading the connector', async () => { + const outcome = await performUpdateKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + updates: {}, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('classifies a sub-hourly interval on an unentitled workspace as forbidden', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + mockHasWorkspaceLiveSyncAccess.mockResolvedValue(false) + + const outcome = await performUpdateKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + updates: { syncIntervalMinutes: 5 }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'forbidden' }) + expect(mockHasWorkspaceLiveSyncAccess).toHaveBeenCalledWith('ws-1') + }) + + it('leaves a caller-supplied validator to reject a bad source config', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + + const outcome = await performUpdateKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + updates: { sourceConfig: { database: 'gone' } }, + validateSourceConfig: async () => ({ + message: 'Database not found', + errorCode: 'validation' as const, + }), + }) + + expect(outcome).toMatchObject({ + success: false, + errorCode: 'validation', + error: 'Database not found', + }) + }) + + it('preserves the failure class the validator chose', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + + // A stale stored credential kept the route's 401; collapsing every + // rejection to `validation` had flattened it (and the 409) into a 400. + const outcome = await performUpdateKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + updates: { sourceConfig: { database: 'x' } }, + validateSourceConfig: async () => ({ + message: 'Failed to refresh access token. Please reconnect your account.', + errorCode: 'unauthorized' as const, + }), + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'unauthorized' }) + }) + + it('clears the failure counters when a paused connector is resumed', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', status: 'active' }, + ]) + + const outcome = await performUpdateKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + updates: { status: 'active' }, + }) + + expect(outcome).toMatchObject({ success: true }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ consecutiveFailures: 0, lastSyncError: null }) + ) + }) +}) + +describe('performSyncKnowledgeConnector', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockDispatchSync.mockResolvedValue(undefined) + }) + + afterAll(resetDbChainMock) + + it('resolves the payer before writing the audit, not after', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', status: 'active' }, + ]) + const rejects = vi.fn().mockRejectedValue(new Error('billing attribution header is malformed')) + + const outcome = await performSyncKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + resolveBillingAttribution: rejects, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'internal' }) + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockDispatchSync).not.toHaveBeenCalled() + }) + + it('refuses to stack a sync on one already running', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', status: 'syncing' }, + ]) + + const outcome = await performSyncKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + resolveBillingAttribution, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'conflict' }) + // A rejected request never pays for the payer lookup. + expect(resolveBillingAttribution).not.toHaveBeenCalled() + expect(mockDispatchSync).not.toHaveBeenCalled() + }) + + it('dispatches and records who asked for it', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', status: 'active' }, + ]) + + const outcome = await performSyncKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + resolveBillingAttribution, + rehydrate: true, + }) + + expect(outcome).toMatchObject({ success: true }) + expect(mockDispatchSync).toHaveBeenCalledWith('conn-1', { + billingAttribution: BILLING, + requestId: 'req-1', + rehydrate: true, + }) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'user-1', + metadata: expect.objectContaining({ syncType: 'manual-rehydrate' }), + }) + ) + }) + + it('rejects a knowledge base with no workspace to bill', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', status: 'active' }, + ]) + + const outcome = await performSyncKnowledgeConnector({ + ...ACTOR, + knowledgeBase: { ...KB, workspaceId: null }, + connectorId: 'conn-1', + resolveBillingAttribution, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'conflict' }) + }) +}) diff --git a/apps/sim/lib/knowledge/orchestration/connectors.ts b/apps/sim/lib/knowledge/orchestration/connectors.ts new file mode 100644 index 00000000000..01d18f5ccea --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/connectors.ts @@ -0,0 +1,735 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { + document, + embedding, + knowledgeBase, + knowledgeBaseTagDefinitions, + knowledgeConnector, +} from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray, isNull, sql } from 'drizzle-orm' +import { encryptApiKey } from '@/lib/api-key/crypto' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { hasWorkspaceLiveSyncAccess } from '@/lib/billing/core/subscription' +import { OrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { allocateTagSlots } from '@/lib/knowledge/constants' +import { deleteDocumentStorageFiles } from '@/lib/knowledge/documents/service' +import { + auditActorFields, + classifyKnowledgeFailure, + fail, + type KnowledgeOperationContext, + type KnowledgeOrchestrationResult, +} from '@/lib/knowledge/orchestration/shared' +import { cleanupUnusedTagDefinitions, createTagDefinition } from '@/lib/knowledge/tags/service' +import { captureServerEvent } from '@/lib/posthog/server' + +const logger = createLogger('KnowledgeConnectorOrchestration') + +/** + * The connector registry and the sync queue are loaded on demand rather than + * imported at module scope. Both pull in every connector's SDK and the whole + * sync engine, and this module is re-exported from the knowledge orchestration + * barrel — a static edge would drag that graph into the bundle of every route + * that merely creates a knowledge base or uploads a document. + */ +async function loadDispatchSync() { + return (await import('@/lib/knowledge/connectors/queue')).dispatchSync +} + +/** A connector row exactly as stored, including its encrypted API key. */ +export type KnowledgeConnectorRow = typeof knowledgeConnector.$inferSelect +type ConnectorRow = KnowledgeConnectorRow +/** The connector row as it reaches every caller: never carrying the stored API key. */ +export type ConnectorWithoutSecret = Omit + +/** A refused `sourceConfig`, with the failure class the caller wants surfaced. */ +export interface SourceConfigRejection { + message: string + errorCode: OrchestrationErrorCode +} + +/** The knowledge base a connector operation targets, already authorized by the caller. */ +export interface ConnectorKnowledgeBase { + id: string + name: string + workspaceId: string | null +} + +function withoutSecret(row: ConnectorRow): ConnectorWithoutSecret { + const { encryptedApiKey: _encryptedApiKey, ...rest } = row + return rest +} + +/** + * Rejects a sub-hourly sync interval on a workspace without the plan for it. + * `0` disables scheduled syncs and is always allowed. + */ +async function assertLiveSyncAllowed( + workspaceId: string, + syncIntervalMinutes: number | undefined +): Promise { + if (syncIntervalMinutes === undefined || syncIntervalMinutes <= 0 || syncIntervalMinutes >= 60) { + return + } + if (!(await hasWorkspaceLiveSyncAccess(workspaceId))) { + throw new OrchestrationError('forbidden', 'Live sync requires a Max or Enterprise plan') + } +} + +export interface PerformCreateKnowledgeConnectorParams extends KnowledgeOperationContext { + knowledgeBase: ConnectorKnowledgeBase + connectorType: string + credentialId?: string + apiKey?: string + sourceConfig: Record + syncIntervalMinutes: number + /** + * Resolves the payer the sync is billed to. A thunk so a request rejected by + * a guard never pays for the lookup, and so the payer is read at the moment + * the sync is dispatched. + */ + resolveBillingAttribution: () => Promise + /** + * Resolves an OAuth credential to its access token. Supplied by the caller + * because credential lookup is scoped to the requesting identity. + */ + resolveAccessToken: (credentialId: string) => Promise +} + +export type PerformConnectorResult = KnowledgeOrchestrationResult<{ + connector: ConnectorWithoutSecret +}> + +/** + * Creates a connector on a knowledge base and dispatches its first sync. + * + * The tag-slot allocation and the connector insert share one transaction under + * the knowledge base's row lock, so a knowledge base archived mid-request can + * never end up with a live connector, and a partial slot allocation cannot + * outlive a failed insert. + */ +export async function performCreateKnowledgeConnector( + params: PerformCreateKnowledgeConnectorParams +): Promise { + const { + knowledgeBase: kb, + connectorType, + credentialId, + apiKey, + sourceConfig, + syncIntervalMinutes, + resolveBillingAttribution, + resolveAccessToken, + request, + source, + } = params + const requestId = params.requestId ?? generateRequestId() + + if (!kb.workspaceId) { + return fail('Knowledge base is missing workspace billing context', 'conflict') + } + const workspaceId = kb.workspaceId + + const { CONNECTOR_REGISTRY } = await import('@/connectors/registry.server') + const connectorConfig = CONNECTOR_REGISTRY[connectorType] + if (!connectorConfig) { + return fail(`Unknown connector type: ${connectorType}`, 'validation') + } + + try { + await assertLiveSyncAllowed(workspaceId, syncIntervalMinutes) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Create ${connectorType} connector`) + } + + let resolvedCredentialId: string | null = null + let resolvedEncryptedApiKey: string | null = null + let accessToken: string + + if (connectorConfig.auth.mode === 'apiKey') { + if (!apiKey) { + return fail('API key is required', 'validation') + } + accessToken = apiKey + } else { + if (!credentialId) { + return fail('Credential is required', 'validation') + } + let token: string | null + try { + token = await resolveAccessToken(credentialId) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Create ${connectorType} connector`) + } + if (!token) { + return fail('Credential has no access token. Please reconnect your account.', 'validation') + } + accessToken = token + resolvedCredentialId = credentialId + } + + const configValidation = await connectorConfig.validateConfig(accessToken, sourceConfig) + if (!configValidation.valid) { + return fail(configValidation.error || 'Invalid source configuration', 'validation') + } + + if (connectorConfig.auth.mode === 'apiKey' && apiKey) { + resolvedEncryptedApiKey = (await encryptApiKey(apiKey)).encrypted + } + + let finalSourceConfig: Record = { ...sourceConfig } + 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, kb.id)) + + 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 fail( + `No available tag slots. Could not assign: ${skippedTags.join(', ')}`, + 'validation' + ) + } + + finalSourceConfig = { ...finalSourceConfig, tagSlotMapping } + } + + // Resolved before the write, not after: every guard that can cheaply reject + // the request has already run, and `requireBillingAttributionHeader` throws on + // a malformed header. Resolving it post-commit would leave a live connector + // behind a 500 and let a retry create a duplicate. + let billingAttribution: BillingAttributionSnapshot + try { + billingAttribution = await resolveBillingAttribution() + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Create ${connectorType} connector`) + } + + const now = new Date() + const connectorId = generateId() + const nextSyncAt = + syncIntervalMinutes > 0 ? new Date(now.getTime() + syncIntervalMinutes * 60 * 1000) : null + + let created: ConnectorRow + try { + created = await db.transaction(async (tx) => { + await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${kb.id} FOR UPDATE`) + + const activeKb = await tx + .select({ id: knowledgeBase.id }) + .from(knowledgeBase) + .where(and(eq(knowledgeBase.id, kb.id), isNull(knowledgeBase.deletedAt))) + .limit(1) + + if (activeKb.length === 0) { + throw new OrchestrationError('not_found', 'Knowledge base not found') + } + + for (const [semanticId, slot] of Object.entries(newTagSlots)) { + const td = connectorConfig.tagDefinitions?.find((d) => d.id === semanticId) + if (!td) continue + await createTagDefinition( + { + knowledgeBaseId: kb.id, + tagSlot: slot, + displayName: td.displayName, + fieldType: td.fieldType, + }, + requestId, + tx + ) + } + + const [row] = await tx + .insert(knowledgeConnector) + .values({ + id: connectorId, + knowledgeBaseId: kb.id, + connectorType, + credentialId: resolvedCredentialId, + encryptedApiKey: resolvedEncryptedApiKey, + sourceConfig: finalSourceConfig, + syncIntervalMinutes, + status: 'active', + nextSyncAt, + createdAt: now, + updatedAt: now, + }) + .returning() + + return row + }) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Create ${connectorType} connector`) + } + + logger.info(`[${requestId}] Created connector ${connectorId} for KB ${kb.id}`) + + captureServerEvent( + params.userId, + 'knowledge_base_connector_added', + { + knowledge_base_id: kb.id, + workspace_id: workspaceId, + connector_type: connectorType, + sync_interval_minutes: syncIntervalMinutes, + }, + { + groups: { workspace: workspaceId }, + setOnce: { first_connector_added_at: new Date().toISOString() }, + } + ) + + recordAudit({ + workspaceId, + ...auditActorFields(params), + action: AuditAction.CONNECTOR_CREATED, + resourceType: AuditResourceType.CONNECTOR, + resourceId: connectorId, + resourceName: connectorType, + description: `Created ${connectorType} connector for knowledge base "${kb.name}"`, + metadata: { + source, + knowledgeBaseId: kb.id, + knowledgeBaseName: kb.name, + connectorType, + syncIntervalMinutes, + authMode: connectorConfig.auth.mode, + }, + ...(request ? { request } : {}), + }) + + const dispatchSync = await loadDispatchSync() + dispatchSync(connectorId, { billingAttribution, requestId }).catch((error) => { + logger.error( + `[${requestId}] Failed to dispatch initial sync for connector ${connectorId}`, + error + ) + }) + + return { success: true, connector: withoutSecret(created) } +} + +export interface PerformUpdateKnowledgeConnectorParams extends KnowledgeOperationContext { + knowledgeBase: ConnectorKnowledgeBase + connectorId: string + updates: { + sourceConfig?: Record + syncIntervalMinutes?: number + status?: 'active' | 'paused' + } + /** + * Validates a replacement `sourceConfig` against the live source. Supplied by + * the caller because resolving the connector's token needs the requesting + * identity. Returning a rejection fails the update. + * + * The rejection carries its own `errorCode` so a stale credential and a bad + * config stay distinguishable — collapsing every rejection to `validation` + * flattened the route's 401 and 409 into a 400. + */ + validateSourceConfig?: ( + connector: KnowledgeConnectorRow, + sourceConfig: Record + ) => Promise +} + +/** Loads an active connector scoped to its knowledge base. */ +export async function getKnowledgeConnector( + knowledgeBaseId: string, + connectorId: string +): Promise { + const [row] = await db + .select() + .from(knowledgeConnector) + .where( + and( + eq(knowledgeConnector.id, connectorId), + eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) + ) + .limit(1) + + return row ?? null +} + +/** Applies a connector configuration change and records it against the actor. */ +export async function performUpdateKnowledgeConnector( + params: PerformUpdateKnowledgeConnectorParams +): Promise { + const { knowledgeBase: kb, connectorId, updates, validateSourceConfig, request, source } = params + const requestId = params.requestId ?? generateRequestId() + + const updatedFields = Object.keys(updates).filter( + (key) => updates[key as keyof typeof updates] !== undefined + ) + if (updatedFields.length === 0) { + return fail( + 'At least one of sourceConfig, syncIntervalMinutes, or status is required', + 'validation' + ) + } + + const existing = await getKnowledgeConnector(kb.id, connectorId) + if (!existing) { + return fail('Connector not found', 'not_found') + } + + if (updates.syncIntervalMinutes !== undefined) { + if (!kb.workspaceId && updates.syncIntervalMinutes > 0 && updates.syncIntervalMinutes < 60) { + return fail('Knowledge base is missing workspace billing context', 'conflict') + } + if (kb.workspaceId) { + try { + await assertLiveSyncAllowed(kb.workspaceId, updates.syncIntervalMinutes) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Update connector ${connectorId}`) + } + } + } + + if (updates.sourceConfig !== undefined && validateSourceConfig) { + const rejection = await validateSourceConfig(existing, updates.sourceConfig) + if (rejection) { + return fail(rejection.message, rejection.errorCode) + } + } + + const values: Partial = { updatedAt: new Date() } + if (updates.sourceConfig !== undefined) { + values.sourceConfig = updates.sourceConfig + } + if (updates.syncIntervalMinutes !== undefined) { + values.syncIntervalMinutes = updates.syncIntervalMinutes + values.nextSyncAt = + updates.syncIntervalMinutes > 0 + ? new Date(Date.now() + updates.syncIntervalMinutes * 60 * 1000) + : null + } + if (updates.status !== undefined) { + values.status = updates.status + if (updates.status === 'active') { + values.consecutiveFailures = 0 + values.lastSyncError = null + // Resuming a paused connector syncs immediately unless this same request + // set a schedule, which then owns the next run. + if (values.nextSyncAt === undefined) { + values.nextSyncAt = new Date() + } + } + } + + let updated: ConnectorRow + try { + const [row] = await db + .update(knowledgeConnector) + .set(values) + .where( + and( + eq(knowledgeConnector.id, connectorId), + eq(knowledgeConnector.knowledgeBaseId, kb.id), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) + ) + .returning() + + if (!row) { + return fail('Connector not found', 'not_found') + } + updated = row + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Update connector ${connectorId}`) + } + + recordAudit({ + workspaceId: kb.workspaceId, + ...auditActorFields(params), + action: AuditAction.CONNECTOR_UPDATED, + resourceType: AuditResourceType.CONNECTOR, + resourceId: connectorId, + resourceName: updated.connectorType, + description: `Updated connector for knowledge base "${kb.name}"`, + metadata: { + source, + knowledgeBaseId: kb.id, + knowledgeBaseName: kb.name, + connectorType: updated.connectorType, + updatedFields, + ...(updates.syncIntervalMinutes !== undefined && { + syncIntervalMinutes: updates.syncIntervalMinutes, + }), + ...(updates.status !== undefined && { newStatus: updates.status }), + }, + ...(request ? { request } : {}), + }) + + return { success: true, connector: withoutSecret(updated) } +} + +export interface PerformDeleteKnowledgeConnectorParams extends KnowledgeOperationContext { + knowledgeBase: ConnectorKnowledgeBase + connectorId: string + /** + * Also hard-delete the documents the connector produced. Defaults to keeping + * them, which turns them into ordinary standalone knowledge base entries. + */ + deleteDocuments?: boolean +} + +/** What actually happened to the connector's documents, for the caller to report. */ +export type PerformDeleteKnowledgeConnectorResult = KnowledgeOrchestrationResult<{ + documentsDeleted: number + documentsKept: number +}> + +/** + * Hard-deletes a connector, either removing the documents it produced or + * releasing them as standalone entries. + * + * Returns the counts so callers state what happened rather than assert it. The + * copilot tool used to reach this through an internal HTTP self-call that sent + * no query string, so it always took the keep-documents default while telling + * the user the documents had been removed. + */ +export async function performDeleteKnowledgeConnector( + params: PerformDeleteKnowledgeConnectorParams +): Promise { + const { knowledgeBase: kb, connectorId, request, source } = params + const deleteDocuments = params.deleteDocuments ?? false + const requestId = params.requestId ?? generateRequestId() + + const existing = await getKnowledgeConnector(kb.id, connectorId) + if (!existing) { + return fail('Connector not found', 'not_found') + } + + let deletedDocs: Array<{ id: string; fileUrl: string }> + let docCount: number + try { + ;({ 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, kb.id), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) + ) + .returning({ id: knowledgeConnector.id }) + + if (deletedConnectors.length === 0) { + throw new OrchestrationError('not_found', 'Connector not found') + } + + return { deletedDocs: deleteDocuments ? docs : [], docCount: docs.length } + })) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Delete connector ${connectorId}`) + } + + if (deleteDocuments) { + await Promise.all([ + deletedDocs.length > 0 + ? deleteDocumentStorageFiles( + deletedDocs.map((doc) => ({ ...doc, workspaceId: kb.workspaceId })), + requestId + ) + : Promise.resolve(), + cleanupUnusedTagDefinitions(kb.id, requestId).catch((error) => { + logger.warn(`[${requestId}] Failed to cleanup tag definitions`, error) + }), + ]) + } + + logger.info( + `[${requestId}] Deleted connector ${connectorId}${deleteDocuments ? ` and ${docCount} documents` : `, kept ${docCount} documents`}` + ) + + captureServerEvent( + params.userId, + 'knowledge_base_connector_removed', + { + knowledge_base_id: kb.id, + workspace_id: kb.workspaceId ?? '', + connector_type: existing.connectorType, + documents_deleted: deleteDocuments ? docCount : 0, + }, + kb.workspaceId ? { groups: { workspace: kb.workspaceId } } : undefined + ) + + recordAudit({ + workspaceId: kb.workspaceId, + ...auditActorFields(params), + action: AuditAction.CONNECTOR_DELETED, + resourceType: AuditResourceType.CONNECTOR, + resourceId: connectorId, + resourceName: existing.connectorType, + description: `Deleted connector from knowledge base "${kb.name}"`, + metadata: { + source, + knowledgeBaseId: kb.id, + knowledgeBaseName: kb.name, + connectorType: existing.connectorType, + deleteDocuments, + documentsDeleted: deleteDocuments ? docCount : 0, + documentsKept: deleteDocuments ? 0 : docCount, + }, + ...(request ? { request } : {}), + }) + + return { + success: true, + documentsDeleted: deleteDocuments ? docCount : 0, + documentsKept: deleteDocuments ? 0 : docCount, + } +} + +export interface PerformSyncKnowledgeConnectorParams extends KnowledgeOperationContext { + knowledgeBase: ConnectorKnowledgeBase + connectorId: string + /** + * Resolves the payer the sync is billed to. A thunk so a request rejected by + * a guard never pays for the lookup. + */ + resolveBillingAttribution: () => Promise + /** Re-fetch and re-index every already-synced document, not only changed ones. */ + rehydrate?: boolean +} + +export type PerformSyncKnowledgeConnectorResult = KnowledgeOrchestrationResult + +/** Triggers a manual sync for a connector and records who asked for it. */ +export async function performSyncKnowledgeConnector( + params: PerformSyncKnowledgeConnectorParams +): Promise { + const { knowledgeBase: kb, connectorId, resolveBillingAttribution, request, source } = params + const rehydrate = params.rehydrate ?? false + const requestId = params.requestId ?? generateRequestId() + + const connector = await getKnowledgeConnector(kb.id, connectorId) + if (!connector) { + return fail('Connector not found', 'not_found') + } + if (connector.status === 'syncing') { + return fail('Sync already in progress', 'conflict') + } + if (!kb.workspaceId) { + return fail('Knowledge base is missing workspace billing context', 'conflict') + } + // Resolved before the audit is written, so a rejected payer lookup returns a + // classified failure rather than escaping as a 500 with a sync already recorded. + let billingAttribution: BillingAttributionSnapshot + try { + billingAttribution = await resolveBillingAttribution() + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Sync connector ${connectorId}`) + } + + logger.info( + `[${requestId}] Manual sync${rehydrate ? ' (full rehydrate)' : ''} triggered for connector ${connectorId}` + ) + + captureServerEvent( + params.userId, + 'knowledge_base_connector_synced', + { + knowledge_base_id: kb.id, + workspace_id: kb.workspaceId ?? '', + connector_type: connector.connectorType, + }, + kb.workspaceId ? { groups: { workspace: kb.workspaceId } } : undefined + ) + + recordAudit({ + workspaceId: kb.workspaceId, + ...auditActorFields(params), + action: AuditAction.CONNECTOR_SYNCED, + resourceType: AuditResourceType.CONNECTOR, + resourceId: connectorId, + resourceName: connector.connectorType, + description: `Triggered manual sync for connector on knowledge base "${kb.name}"`, + metadata: { + source, + knowledgeBaseId: kb.id, + knowledgeBaseName: kb.name, + connectorType: connector.connectorType, + connectorStatus: connector.status, + syncType: rehydrate ? 'manual-rehydrate' : 'manual', + }, + ...(request ? { request } : {}), + }) + + const dispatchSync = await loadDispatchSync() + dispatchSync(connectorId, { billingAttribution, requestId, rehydrate }).catch((error) => { + logger.error( + `[${requestId}] Failed to dispatch manual sync for connector ${connectorId}`, + error + ) + }) + + return { success: true } +} diff --git a/apps/sim/lib/knowledge/orchestration/documents.test.ts b/apps/sim/lib/knowledge/orchestration/documents.test.ts new file mode 100644 index 00000000000..bfbe1bdc403 --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/documents.test.ts @@ -0,0 +1,329 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCaptureServerEvent, + mockCreateDocumentRecords, + mockCreateSingleDocument, + mockDeleteDocument, + mockMarkDocumentAsFailedTimeout, + mockProcessDocumentAsync, + mockProcessDocumentsWithQueue, + mockRecordAudit, + mockRetryDocumentProcessing, + mockUpdateDocument, +} = vi.hoisted(() => ({ + mockCaptureServerEvent: vi.fn(), + mockCreateDocumentRecords: vi.fn(), + mockCreateSingleDocument: vi.fn(), + mockDeleteDocument: vi.fn(), + mockMarkDocumentAsFailedTimeout: vi.fn(), + mockProcessDocumentAsync: vi.fn(), + mockProcessDocumentsWithQueue: vi.fn(), + mockRecordAudit: vi.fn(), + mockRetryDocumentProcessing: vi.fn(), + mockUpdateDocument: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + DOCUMENT_UPLOADED: 'document.uploaded', + DOCUMENT_UPDATED: 'document.updated', + DOCUMENT_DELETED: 'document.deleted', + }, + AuditResourceType: { DOCUMENT: 'document' }, + recordAudit: mockRecordAudit, +})) +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { knowledgeBaseDocumentsUploaded: vi.fn() }, +})) +vi.mock('@/lib/knowledge/documents/service', () => ({ + createDocumentRecords: mockCreateDocumentRecords, + createSingleDocument: mockCreateSingleDocument, + deleteDocument: mockDeleteDocument, + markDocumentAsFailedTimeout: mockMarkDocumentAsFailedTimeout, + processDocumentAsync: mockProcessDocumentAsync, + processDocumentsWithQueue: mockProcessDocumentsWithQueue, + retryDocumentProcessing: mockRetryDocumentProcessing, + updateDocument: mockUpdateDocument, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent })) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + performDeleteKnowledgeDocument, + performMarkKnowledgeDocumentTimedOut, + performRetryKnowledgeDocumentProcessing, + performUpdateKnowledgeDocument, + performUploadKnowledgeDocument, + performUploadKnowledgeDocuments, +} from '@/lib/knowledge/orchestration/documents' + +const KB = { id: 'kb-1', name: 'Docs', workspaceId: 'ws-1' } +const FILE = { + filename: 'report.pdf', + fileUrl: 'https://storage/report.pdf', + fileSize: 1024, + mimeType: 'application/pdf', +} +const ACTOR = { userId: 'user-1', source: 'agent' as const, requestId: 'req-1' } + +describe('performUploadKnowledgeDocument', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreateSingleDocument.mockResolvedValue({ id: 'doc-1', filename: 'report.pdf' }) + mockProcessDocumentsWithQueue.mockResolvedValue(undefined) + mockProcessDocumentAsync.mockResolvedValue(undefined) + }) + + it('audits an agent upload, which the copilot path never did', async () => { + const outcome = await performUploadKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: FILE, + }) + + expect(outcome).toMatchObject({ success: true }) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'user-1', + resourceId: 'doc-1', + resourceName: 'report.pdf', + metadata: expect.objectContaining({ source: 'agent', knowledgeBaseId: 'kb-1' }), + }) + ) + }) + + it('records the document owner the caller names, not the acting user', async () => { + // A workspace API key bills and owns as the workspace account, while the + // acting user stays the audit actor. + await performUploadKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: FILE, + uploadedBy: 'workspace-owner', + }) + + expect(mockCreateSingleDocument).toHaveBeenCalledWith(FILE, 'kb-1', 'req-1', 'workspace-owner') + }) + + it('starts no indexing unless the caller asks for it', async () => { + await performUploadKnowledgeDocument({ ...ACTOR, knowledgeBase: KB, document: FILE }) + + expect(mockProcessDocumentsWithQueue).not.toHaveBeenCalled() + expect(mockProcessDocumentAsync).not.toHaveBeenCalled() + }) + + it.each([ + { startProcessing: 'queue' as const, expected: mockProcessDocumentsWithQueue }, + { startProcessing: 'async' as const, expected: mockProcessDocumentAsync }, + ])('hands the record to the $startProcessing pipeline', async ({ startProcessing, expected }) => { + await performUploadKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: FILE, + startProcessing, + }) + + expect(expected).toHaveBeenCalled() + }) + + it('classifies a storage-quota rejection as too large, by class not message', async () => { + mockCreateSingleDocument.mockRejectedValue( + new OrchestrationError('payload_too_large', 'Storage limit exceeded. Used: 5.10GB') + ) + + const outcome = await performUploadKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: FILE, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'payload_too_large' }) + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('classifies a foreign file reference as forbidden', async () => { + mockCreateSingleDocument.mockRejectedValue( + new OrchestrationError('forbidden', 'Document file is not owned by this knowledge base') + ) + + expect( + (await performUploadKnowledgeDocument({ ...ACTOR, knowledgeBase: KB, document: FILE })) + .errorCode + ).toBe('forbidden') + }) +}) + +describe('performUploadKnowledgeDocuments', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreateDocumentRecords.mockResolvedValue([ + { documentId: 'doc-1', filename: 'a.pdf' }, + { documentId: 'doc-2', filename: 'b.pdf' }, + ]) + mockProcessDocumentsWithQueue.mockResolvedValue(undefined) + }) + + it('admits the whole batch in one call and queues it', async () => { + const outcome = await performUploadKnowledgeDocuments({ + ...ACTOR, + knowledgeBase: KB, + documents: [FILE, { ...FILE, filename: 'b.pdf' }], + }) + + expect(outcome).toMatchObject({ success: true }) + expect(mockCreateDocumentRecords).toHaveBeenCalledTimes(1) + expect(mockProcessDocumentsWithQueue).toHaveBeenCalledTimes(1) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ resourceName: '2 document(s)' }) + ) + }) + + it('rejects an empty batch before touching the service', async () => { + const outcome = await performUploadKnowledgeDocuments({ + ...ACTOR, + knowledgeBase: KB, + documents: [], + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + expect(mockCreateDocumentRecords).not.toHaveBeenCalled() + }) +}) + +describe('performUpdateKnowledgeDocument', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUpdateDocument.mockResolvedValue({ id: 'doc-1', filename: 'renamed.pdf' }) + }) + + it('rejects an update that names nothing before touching the service', async () => { + const outcome = await performUpdateKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: { id: 'doc-1', filename: 'report.pdf' }, + updates: { filename: undefined, enabled: undefined }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + expect(mockUpdateDocument).not.toHaveBeenCalled() + }) + + it('names the changed fields in the audit metadata', async () => { + await performUpdateKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: { id: 'doc-1', filename: 'report.pdf' }, + updates: { filename: 'renamed.pdf', enabled: false }, + }) + + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + resourceName: 'renamed.pdf', + metadata: expect.objectContaining({ + updatedFields: ['filename', 'enabled'], + enabled: false, + }), + }) + ) + }) +}) + +describe('performDeleteKnowledgeDocument', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDeleteDocument.mockResolvedValue({ success: true, message: 'ok' }) + }) + + it('audits the deletion against the acting user', async () => { + const outcome = await performDeleteKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: { id: 'doc-1', filename: 'report.pdf', fileSize: 10, mimeType: 'application/pdf' }, + }) + + expect(outcome).toMatchObject({ success: true }) + expect(mockDeleteDocument).toHaveBeenCalledWith('doc-1', 'req-1') + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ actorId: 'user-1', resourceId: 'doc-1' }) + ) + expect(mockCaptureServerEvent).toHaveBeenCalled() + }) + + it('emits no telemetry when the delete fails', async () => { + mockDeleteDocument.mockRejectedValue(new Error('deadlock detected')) + + const outcome = await performDeleteKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: { id: 'doc-1', filename: 'report.pdf' }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'internal' }) + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) +}) + +describe('document processing state changes', () => { + beforeEach(() => vi.clearAllMocks()) + + it('refuses to time out a document that is not processing', async () => { + const outcome = await performMarkKnowledgeDocumentTimedOut({ + document: { id: 'doc-1', processingStatus: 'completed', processingStartedAt: new Date() }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + expect(mockMarkDocumentAsFailedTimeout).not.toHaveBeenCalled() + }) + + it('refuses to time out a document with no processing start time', async () => { + const outcome = await performMarkKnowledgeDocumentTimedOut({ + document: { id: 'doc-1', processingStatus: 'processing', processingStartedAt: null }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + }) + + it('surfaces a too-soon timeout as caller-fixable, not a fault', async () => { + mockMarkDocumentAsFailedTimeout.mockRejectedValue( + new Error('Document has not been processing long enough to be considered dead') + ) + + const outcome = await performMarkKnowledgeDocumentTimedOut({ + document: { id: 'doc-1', processingStatus: 'processing', processingStartedAt: new Date() }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + }) + + it('refuses to retry a document that has not failed', async () => { + const outcome = await performRetryKnowledgeDocumentProcessing({ + knowledgeBaseId: 'kb-1', + document: { ...FILE, id: 'doc-1', processingStatus: 'completed' }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + expect(mockRetryDocumentProcessing).not.toHaveBeenCalled() + }) + + it('re-queues a failed document and never audits it', async () => { + mockRetryDocumentProcessing.mockResolvedValue({ + success: true, + status: 'pending', + message: 'Document retry processing started', + }) + + const outcome = await performRetryKnowledgeDocumentProcessing({ + knowledgeBaseId: 'kb-1', + document: { ...FILE, id: 'doc-1', processingStatus: 'failed' }, + requestId: 'req-1', + }) + + expect(outcome).toMatchObject({ success: true, status: 'pending' }) + // No document state the user chose changes, so there is nothing to record. + expect(mockRecordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/orchestration/documents.ts b/apps/sim/lib/knowledge/orchestration/documents.ts new file mode 100644 index 00000000000..d0111d53289 --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/documents.ts @@ -0,0 +1,486 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { PlatformEvents } from '@/lib/core/telemetry' +import { generateRequestId } from '@/lib/core/utils/request' +import { + createDocumentRecords, + createSingleDocument, + type DocumentData, + deleteDocument, + markDocumentAsFailedTimeout, + type ProcessingOptions, + processDocumentAsync, + processDocumentsWithQueue, + retryDocumentProcessing, + updateDocument, +} from '@/lib/knowledge/documents/service' +import { + auditActorFields, + classifyKnowledgeFailure, + fail, + type KnowledgeOperationContext, + type KnowledgeOrchestrationResult, +} from '@/lib/knowledge/orchestration/shared' +import { captureServerEvent } from '@/lib/posthog/server' + +const logger = createLogger('KnowledgeDocumentOrchestration') + +/** The knowledge base a document operation targets, already authorized by the caller. */ +export interface KnowledgeBaseTarget { + id: string + name?: string | null + workspaceId: string | null +} + +export interface KnowledgeDocumentInput { + filename: string + fileUrl: string + fileSize: number + mimeType: string + documentTagsData?: string + tag1?: string + tag2?: string + tag3?: string + tag4?: string + tag5?: string + tag6?: string + tag7?: string +} + +/** + * How the created record is handed to the indexing pipeline. + * + * `queue` runs the shared bounded-concurrency queue; `async` starts one + * detached processing run. Omitted starts nothing — the internal single-document + * route deliberately only creates the row and leaves indexing to its caller. + */ +export type KnowledgeDocumentProcessing = 'queue' | 'async' + +export type CreatedKnowledgeDocument = Awaited> + +export interface PerformUploadKnowledgeDocumentParams extends KnowledgeOperationContext { + knowledgeBase: KnowledgeBaseTarget + document: KnowledgeDocumentInput + startProcessing?: KnowledgeDocumentProcessing + processingOptions?: ProcessingOptions + billingAttribution?: BillingAttributionSnapshot + /** Row owner recorded on the document; defaults to the acting user. */ + uploadedBy?: string | null +} + +export type PerformUploadKnowledgeDocumentResult = KnowledgeOrchestrationResult<{ + document: CreatedKnowledgeDocument +}> + +function auditUpload( + params: KnowledgeOperationContext & { knowledgeBase: KnowledgeBaseTarget }, + entry: { resourceId: string; resourceName: string; description: string; metadata: object } +) { + recordAudit({ + workspaceId: params.knowledgeBase.workspaceId, + ...auditActorFields(params), + action: AuditAction.DOCUMENT_UPLOADED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: entry.resourceId, + resourceName: entry.resourceName, + description: entry.description, + metadata: { + source: params.source, + knowledgeBaseId: params.knowledgeBase.id, + knowledgeBaseName: params.knowledgeBase.name, + ...entry.metadata, + }, + ...(params.request ? { request: params.request } : {}), + }) +} + +function captureUpload( + params: KnowledgeOperationContext & { knowledgeBase: KnowledgeBaseTarget }, + documentCount: number, + uploadType: 'single' | 'bulk' +) { + const workspaceId = params.knowledgeBase.workspaceId + captureServerEvent( + params.userId, + 'knowledge_base_document_uploaded', + { + knowledge_base_id: params.knowledgeBase.id, + workspace_id: workspaceId ?? '', + document_count: documentCount, + upload_type: uploadType, + }, + { + ...(workspaceId ? { groups: { workspace: workspaceId } } : {}), + setOnce: { first_document_uploaded_at: new Date().toISOString() }, + } + ) +} + +/** + * Adds one already-resolved file to a knowledge base. + * + * Each surface acquires the file differently — a multipart body uploaded to + * workspace storage, a virtual-filesystem reference resolved to a presigned URL, + * a client-supplied URL — so acquisition stays with the caller and this takes + * the resolved `{filename, fileUrl, fileSize, mimeType}`. Everything downstream + * of that (the record, the indexing hand-off, telemetry, and the audit) is + * identical for all of them, which is why the copilot path used to index + * documents that appear nowhere in the audit log. + */ +export async function performUploadKnowledgeDocument( + params: PerformUploadKnowledgeDocumentParams +): Promise { + const { knowledgeBase, document, startProcessing, processingOptions, billingAttribution } = params + const requestId = params.requestId ?? generateRequestId() + + let created: CreatedKnowledgeDocument + try { + created = await createSingleDocument( + document, + knowledgeBase.id, + requestId, + params.uploadedBy ?? params.userId + ) + } catch (error) { + return classifyKnowledgeFailure( + error, + requestId, + `Upload document "${document.filename}" to knowledge base ${knowledgeBase.id}` + ) + } + + const documentData: DocumentData = { + documentId: created.id, + filename: document.filename, + fileUrl: document.fileUrl, + fileSize: document.fileSize, + mimeType: document.mimeType, + } + + if (startProcessing === 'queue') { + processDocumentsWithQueue( + [documentData], + knowledgeBase.id, + processingOptions ?? {}, + requestId, + billingAttribution + ).catch((error: unknown) => { + logger.error(`[${requestId}] Document processing pipeline failed`, { error }) + }) + } else if (startProcessing === 'async') { + processDocumentAsync( + knowledgeBase.id, + created.id, + document, + processingOptions ?? {}, + billingAttribution + ).catch((error: unknown) => { + logger.error(`[${requestId}] Background document processing failed`, { + documentId: created.id, + error: toError(error).message, + }) + }) + } + + PlatformEvents.knowledgeBaseDocumentsUploaded({ + knowledgeBaseId: knowledgeBase.id, + documentsCount: 1, + uploadType: 'single', + mimeType: document.mimeType, + fileSize: document.fileSize, + }) + captureUpload(params, 1, 'single') + + auditUpload(params, { + resourceId: created.id, + resourceName: document.filename, + description: `Uploaded document "${document.filename}" to knowledge base "${knowledgeBase.name ?? knowledgeBase.id}"`, + metadata: { + fileName: document.filename, + fileType: document.mimeType, + fileSize: document.fileSize, + }, + }) + + return { success: true, document: created } +} + +export interface PerformUploadKnowledgeDocumentsParams extends KnowledgeOperationContext { + knowledgeBase: KnowledgeBaseTarget + documents: KnowledgeDocumentInput[] + processingOptions?: ProcessingOptions + billingAttribution?: BillingAttributionSnapshot + uploadedBy?: string | null +} + +export type PerformUploadKnowledgeDocumentsResult = KnowledgeOrchestrationResult<{ + documents: DocumentData[] +}> + +/** + * Adds many files to a knowledge base in one storage admission and hands the + * whole set to the bounded-concurrency processing queue. + * + * Kept separate from the single-document path because `createDocumentRecords` + * admits the batch's bytes as one unit; running it per document would let a set + * that exceeds the quota commit its first half. + */ +export async function performUploadKnowledgeDocuments( + params: PerformUploadKnowledgeDocumentsParams +): Promise { + const { knowledgeBase, documents, processingOptions, billingAttribution } = params + const requestId = params.requestId ?? generateRequestId() + + if (documents.length === 0) { + return fail('No documents specified', 'validation') + } + + let created: DocumentData[] + try { + created = await createDocumentRecords( + documents, + knowledgeBase.id, + requestId, + params.uploadedBy ?? params.userId + ) + } catch (error) { + return classifyKnowledgeFailure( + error, + requestId, + `Upload ${documents.length} document(s) to knowledge base ${knowledgeBase.id}` + ) + } + + logger.info(`[${requestId}] Starting controlled async processing of ${created.length} documents`) + + processDocumentsWithQueue( + created, + knowledgeBase.id, + processingOptions ?? {}, + requestId, + billingAttribution + ).catch((error: unknown) => { + logger.error(`[${requestId}] Critical error in document processing pipeline`, { error }) + }) + + PlatformEvents.knowledgeBaseDocumentsUploaded({ + knowledgeBaseId: knowledgeBase.id, + documentsCount: created.length, + uploadType: 'bulk', + recipe: processingOptions?.recipe, + }) + captureUpload(params, created.length, 'bulk') + + auditUpload(params, { + resourceId: knowledgeBase.id, + resourceName: `${created.length} document(s)`, + description: `Uploaded ${created.length} document(s) to knowledge base "${knowledgeBase.name ?? knowledgeBase.id}"`, + metadata: { fileCount: created.length }, + }) + + return { success: true, documents: created } +} + +export interface PerformUpdateKnowledgeDocumentParams extends KnowledgeOperationContext { + knowledgeBase: KnowledgeBaseTarget + document: { id: string; filename: string } + updates: Parameters[1] +} + +export type PerformUpdateKnowledgeDocumentResult = KnowledgeOrchestrationResult<{ + document: Awaited> +}> + +/** Renames a document, toggles it, or edits its tags, and records the change. */ +export async function performUpdateKnowledgeDocument( + params: PerformUpdateKnowledgeDocumentParams +): Promise { + const { knowledgeBase, document, updates, request, source } = params + const requestId = params.requestId ?? generateRequestId() + + const updatedFields = Object.keys(updates).filter( + (key) => updates[key as keyof typeof updates] !== undefined + ) + if (updatedFields.length === 0) { + return fail('No updates specified', 'validation') + } + + let updated: Awaited> + try { + updated = await updateDocument(document.id, updates, requestId) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Update document ${document.id}`) + } + + const filename = updates.filename ?? document.filename + + recordAudit({ + workspaceId: knowledgeBase.workspaceId, + ...auditActorFields(params), + action: AuditAction.DOCUMENT_UPDATED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: document.id, + resourceName: filename, + description: `Updated document "${filename}" in knowledge base "${knowledgeBase.name ?? knowledgeBase.id}"`, + metadata: { + source, + knowledgeBaseId: knowledgeBase.id, + knowledgeBaseName: knowledgeBase.name, + fileName: filename, + updatedFields, + ...(updates.enabled !== undefined && { enabled: updates.enabled }), + }, + ...(request ? { request } : {}), + }) + + return { success: true, document: updated } +} + +export interface PerformDeleteKnowledgeDocumentParams extends KnowledgeOperationContext { + knowledgeBase: KnowledgeBaseTarget + document: { id: string; filename: string; fileSize?: number; mimeType?: string } +} + +export type PerformDeleteKnowledgeDocumentResult = KnowledgeOrchestrationResult + +/** Deletes a document and its embeddings, and records the deletion. */ +export async function performDeleteKnowledgeDocument( + params: PerformDeleteKnowledgeDocumentParams +): Promise { + const { knowledgeBase, document, request, source } = params + const requestId = params.requestId ?? generateRequestId() + + try { + await deleteDocument(document.id, requestId) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Delete document ${document.id}`) + } + + logger.info( + `[${requestId}] Deleted document ${document.id} from knowledge base ${knowledgeBase.id}` + ) + + recordAudit({ + workspaceId: knowledgeBase.workspaceId, + ...auditActorFields(params), + action: AuditAction.DOCUMENT_DELETED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: document.id, + resourceName: document.filename, + description: `Deleted document "${document.filename}" from knowledge base "${knowledgeBase.name ?? knowledgeBase.id}"`, + metadata: { + source, + knowledgeBaseId: knowledgeBase.id, + knowledgeBaseName: knowledgeBase.name, + fileName: document.filename, + fileSize: document.fileSize, + mimeType: document.mimeType, + }, + ...(request ? { request } : {}), + }) + + const workspaceId = knowledgeBase.workspaceId + captureServerEvent( + params.userId, + 'knowledge_base_document_deleted', + { knowledge_base_id: knowledgeBase.id, workspace_id: workspaceId ?? '' }, + workspaceId ? { groups: { workspace: workspaceId } } : undefined + ) + + return { success: true } +} + +export interface PerformMarkKnowledgeDocumentTimedOutParams { + document: { + id: string + processingStatus: string + processingStartedAt?: Date | null + } + requestId?: string +} + +export type PerformKnowledgeDocumentProcessingResult = KnowledgeOrchestrationResult<{ + status: string + message: string +}> + +/** + * Marks a document whose processing run died as failed. Not audited: the state + * change is the system conceding a run it lost, not a user editing a document. + */ +export async function performMarkKnowledgeDocumentTimedOut( + params: PerformMarkKnowledgeDocumentTimedOutParams +): Promise { + const { document } = params + const requestId = params.requestId ?? generateRequestId() + + if (document.processingStatus !== 'processing') { + return fail( + `Document is not in processing state (current: ${document.processingStatus})`, + 'validation' + ) + } + if (!document.processingStartedAt) { + return fail('Document has no processing start time', 'validation') + } + + try { + await markDocumentAsFailedTimeout(document.id, document.processingStartedAt, requestId) + } catch (error) { + // The service rejects a document that has not been processing long enough + // to be presumed dead; that is a caller-fixable "try again later", not a fault. + if (!(error instanceof OrchestrationError)) { + return fail(toError(error).message, 'validation') + } + return classifyKnowledgeFailure(error, requestId, `Time out document ${document.id}`) + } + + return { success: true, status: 'failed', message: 'Document marked as failed due to timeout' } +} + +export interface PerformRetryKnowledgeDocumentParams { + knowledgeBaseId: string + document: { + id: string + filename: string + fileUrl: string + fileSize: number + mimeType: string + processingStatus: string + } + billingAttribution?: BillingAttributionSnapshot + requestId?: string +} + +/** Re-queues a failed document for indexing. Not audited: no document state a user chose changes. */ +export async function performRetryKnowledgeDocumentProcessing( + params: PerformRetryKnowledgeDocumentParams +): Promise { + const { knowledgeBaseId, document, billingAttribution } = params + const requestId = params.requestId ?? generateRequestId() + + if (document.processingStatus !== 'failed') { + return fail('Document is not in failed state', 'validation') + } + + try { + const result = await retryDocumentProcessing( + knowledgeBaseId, + document.id, + { + filename: document.filename, + fileUrl: document.fileUrl, + fileSize: document.fileSize, + mimeType: document.mimeType, + }, + requestId, + billingAttribution + ) + return { success: true, status: result.status, message: result.message } + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Retry document ${document.id}`) + } +} diff --git a/apps/sim/lib/knowledge/orchestration/index.ts b/apps/sim/lib/knowledge/orchestration/index.ts index dc44ac10ee9..2f9d033a355 100644 --- a/apps/sim/lib/knowledge/orchestration/index.ts +++ b/apps/sim/lib/knowledge/orchestration/index.ts @@ -1,88 +1,34 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { knowledgeBase } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { eq } from 'drizzle-orm' -import { generateRequestId } from '@/lib/core/utils/request' -import { KnowledgeBaseConflictError, restoreKnowledgeBase } from '@/lib/knowledge/service' - -const logger = createLogger('KnowledgeBaseOrchestration') - -export type KnowledgeOrchestrationErrorCode = 'not_found' | 'conflict' | 'internal' - -export interface RestorableKnowledgeBase { - id: string - name: string - workspaceId: string | null - userId: string -} - -export interface PerformRestoreKnowledgeBaseParams { - knowledgeBaseId: string - userId: string - requestId?: string -} - -export interface PerformRestoreKnowledgeBaseResult { - success: boolean - error?: string - errorCode?: KnowledgeOrchestrationErrorCode - knowledgeBase?: RestorableKnowledgeBase -} - -export async function getRestorableKnowledgeBase( - knowledgeBaseId: string -): Promise { - const [kb] = await db - .select({ - id: knowledgeBase.id, - name: knowledgeBase.name, - workspaceId: knowledgeBase.workspaceId, - userId: knowledgeBase.userId, - }) - .from(knowledgeBase) - .where(eq(knowledgeBase.id, knowledgeBaseId)) - .limit(1) - - return kb ?? null -} - -export async function performRestoreKnowledgeBase( - params: PerformRestoreKnowledgeBaseParams -): Promise { - const { knowledgeBaseId, userId } = params - const requestId = params.requestId ?? generateRequestId() - - const kb = await getRestorableKnowledgeBase(knowledgeBaseId) - if (!kb) { - return { success: false, error: 'Knowledge base not found', errorCode: 'not_found' } - } - - try { - await restoreKnowledgeBase(knowledgeBaseId, requestId) - - logger.info(`[${requestId}] Restored knowledge base ${knowledgeBaseId}`) - - recordAudit({ - workspaceId: kb.workspaceId, - actorId: userId, - action: AuditAction.KNOWLEDGE_BASE_RESTORED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: knowledgeBaseId, - resourceName: kb.name, - description: `Restored knowledge base "${kb.name}"`, - metadata: { - knowledgeBaseName: kb.name, - }, - }) - - return { success: true, knowledgeBase: kb } - } catch (error) { - logger.error(`[${requestId}] Failed to restore knowledge base ${knowledgeBaseId}`, { error }) - if (error instanceof KnowledgeBaseConflictError) { - return { success: false, error: error.message, errorCode: 'conflict' } - } - return { success: false, error: toError(error).message, errorCode: 'internal' } - } -} +export { + type ConnectorKnowledgeBase, + type ConnectorWithoutSecret, + getKnowledgeConnector, + type KnowledgeConnectorRow, + performCreateKnowledgeConnector, + performDeleteKnowledgeConnector, + performSyncKnowledgeConnector, + performUpdateKnowledgeConnector, + type SourceConfigRejection, +} from './connectors' +export { + type CreatedKnowledgeDocument, + type KnowledgeBaseTarget, + type KnowledgeDocumentInput, + performDeleteKnowledgeDocument, + performMarkKnowledgeDocumentTimedOut, + performRetryKnowledgeDocumentProcessing, + performUpdateKnowledgeDocument, + performUploadKnowledgeDocument, + performUploadKnowledgeDocuments, +} from './documents' +export { + type PerformKnowledgeBaseResult, + performCreateKnowledgeBase, + performDeleteKnowledgeBase, + performUpdateKnowledgeBase, +} from './knowledge-bases' +export { + getRestorableKnowledgeBase, + performRestoreKnowledgeBase, + type RestorableKnowledgeBase, +} from './restore' +export type { KnowledgeActor, KnowledgeOperationSource } from './shared' diff --git a/apps/sim/lib/knowledge/orchestration/knowledge-bases.test.ts b/apps/sim/lib/knowledge/orchestration/knowledge-bases.test.ts new file mode 100644 index 00000000000..6aed845a9be --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/knowledge-bases.test.ts @@ -0,0 +1,256 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCaptureServerEvent, + mockCreateKnowledgeBase, + mockDeleteKnowledgeBase, + mockRecordAudit, + mockUpdateKnowledgeBase, +} = vi.hoisted(() => ({ + mockCaptureServerEvent: vi.fn(), + mockCreateKnowledgeBase: vi.fn(), + mockDeleteKnowledgeBase: vi.fn(), + mockRecordAudit: vi.fn(), + mockUpdateKnowledgeBase: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + KNOWLEDGE_BASE_CREATED: 'knowledge_base.created', + KNOWLEDGE_BASE_UPDATED: 'knowledge_base.updated', + KNOWLEDGE_BASE_DELETED: 'knowledge_base.deleted', + }, + AuditResourceType: { KNOWLEDGE_BASE: 'knowledge_base' }, + recordAudit: mockRecordAudit, +})) +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { knowledgeBaseCreated: vi.fn(), knowledgeBaseDeleted: vi.fn() }, +})) +vi.mock('@/lib/knowledge/embeddings', () => ({ + EMBEDDING_DIMENSIONS: 1536, + getConfiguredEmbeddingModel: () => 'text-embedding-3-small', +})) +vi.mock('@/lib/knowledge/service', () => ({ + createKnowledgeBase: mockCreateKnowledgeBase, + deleteKnowledgeBase: mockDeleteKnowledgeBase, + updateKnowledgeBase: mockUpdateKnowledgeBase, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent })) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { DEFAULT_CHUNKING_CONFIG } from '@/lib/knowledge/constants' +import { + performCreateKnowledgeBase, + performDeleteKnowledgeBase, + performUpdateKnowledgeBase, +} from '@/lib/knowledge/orchestration/knowledge-bases' + +const CREATED = { id: 'kb-1', name: 'Docs', description: null, workspaceId: 'ws-1' } + +describe('performCreateKnowledgeBase', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreateKnowledgeBase.mockResolvedValue(CREATED) + }) + + it('applies one chunking default for every caller', async () => { + await performCreateKnowledgeBase({ + userId: 'user-1', + source: 'agent', + workspaceId: 'ws-1', + name: 'Docs', + }) + + // The agent used to default minSize to 1 against the API's 100, so the same + // document chunked differently depending on who created the knowledge base. + expect(mockCreateKnowledgeBase).toHaveBeenCalledWith( + expect.objectContaining({ chunkingConfig: { ...DEFAULT_CHUNKING_CONFIG } }), + expect.any(String) + ) + }) + + it('lets a caller override individual chunking fields', async () => { + await performCreateKnowledgeBase({ + userId: 'user-1', + source: 'api', + workspaceId: 'ws-1', + name: 'Docs', + chunkingConfig: { maxSize: 512 }, + }) + + expect(mockCreateKnowledgeBase).toHaveBeenCalledWith( + expect.objectContaining({ + chunkingConfig: { ...DEFAULT_CHUNKING_CONFIG, maxSize: 512 }, + }), + expect.any(String) + ) + }) + + it('audits an agent-created knowledge base, which the copilot path never did', async () => { + await performCreateKnowledgeBase({ + userId: 'user-1', + source: 'agent', + workspaceId: 'ws-1', + name: 'Docs', + }) + + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'user-1', + resourceId: 'kb-1', + workspaceId: 'ws-1', + metadata: expect.objectContaining({ source: 'agent' }), + }) + ) + }) + + it('carries request provenance into the audit row', async () => { + const request = new Request('https://sim.ai', { headers: { 'user-agent': 'curl/8' } }) + + await performCreateKnowledgeBase({ + userId: 'user-1', + source: 'ui', + workspaceId: 'ws-1', + name: 'Docs', + request, + }) + + expect(mockRecordAudit).toHaveBeenCalledWith(expect.objectContaining({ request })) + }) + + it('classifies a duplicate name as a conflict, not bad input', async () => { + mockCreateKnowledgeBase.mockRejectedValue( + new OrchestrationError('conflict', 'A knowledge base named "Docs" already exists') + ) + + const outcome = await performCreateKnowledgeBase({ + userId: 'user-1', + source: 'ui', + workspaceId: 'ws-1', + name: 'Docs', + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'conflict' }) + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('keeps an unclassified failure internal and records nothing', async () => { + mockCreateKnowledgeBase.mockRejectedValue(new Error('connection terminated')) + + const outcome = await performCreateKnowledgeBase({ + userId: 'user-1', + source: 'ui', + workspaceId: 'ws-1', + name: 'Docs', + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'internal' }) + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) +}) + +describe('performUpdateKnowledgeBase', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUpdateKnowledgeBase.mockResolvedValue({ ...CREATED, name: 'Renamed' }) + }) + + it('rejects an update that names nothing before touching the service', async () => { + const outcome = await performUpdateKnowledgeBase({ + knowledgeBaseId: 'kb-1', + workspaceId: 'ws-1', + userId: 'user-1', + source: 'api', + updates: { name: undefined, description: undefined }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + expect(mockUpdateKnowledgeBase).not.toHaveBeenCalled() + }) + + it('always forwards the actor, so a workspace move is authorized not rejected', async () => { + await performUpdateKnowledgeBase({ + knowledgeBaseId: 'kb-1', + workspaceId: 'ws-1', + userId: 'user-1', + source: 'api', + updates: { workspaceId: 'ws-2' }, + }) + + // The v1 and v2 routes used to omit `actorUserId`, which the service rejects + // outright on a workspace change. + expect(mockUpdateKnowledgeBase).toHaveBeenCalledWith( + 'kb-1', + { workspaceId: 'ws-2' }, + expect.any(String), + { actorUserId: 'user-1' } + ) + }) + + it('files the audit against the destination workspace on a move', async () => { + await performUpdateKnowledgeBase({ + knowledgeBaseId: 'kb-1', + workspaceId: 'ws-1', + userId: 'user-1', + source: 'ui', + updates: { workspaceId: 'ws-2' }, + }) + + expect(mockRecordAudit).toHaveBeenCalledWith(expect.objectContaining({ workspaceId: 'ws-2' })) + }) + + it('classifies a rejected folder as bad input', async () => { + mockUpdateKnowledgeBase.mockRejectedValue( + new OrchestrationError('validation', 'Folder not found in this workspace') + ) + + const outcome = await performUpdateKnowledgeBase({ + knowledgeBaseId: 'kb-1', + workspaceId: 'ws-1', + userId: 'user-1', + source: 'ui', + updates: { folderId: 'folder-elsewhere' }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + }) +}) + +describe('performDeleteKnowledgeBase', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDeleteKnowledgeBase.mockResolvedValue(undefined) + }) + + it('audits the archive against the acting user', async () => { + const outcome = await performDeleteKnowledgeBase({ + knowledgeBase: { id: 'kb-1', name: 'Docs', workspaceId: 'ws-1' }, + userId: 'user-1', + source: 'agent', + requestId: 'req-1', + }) + + expect(outcome.success).toBe(true) + expect(mockDeleteKnowledgeBase).toHaveBeenCalledWith('kb-1', 'req-1') + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ actorId: 'user-1', resourceId: 'kb-1' }) + ) + }) + + it('records nothing when the archive fails', async () => { + mockDeleteKnowledgeBase.mockRejectedValue(new Error('deadlock detected')) + + const outcome = await performDeleteKnowledgeBase({ + knowledgeBase: { id: 'kb-1', name: 'Docs', workspaceId: 'ws-1' }, + userId: 'user-1', + source: 'ui', + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'internal' }) + expect(mockRecordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/orchestration/knowledge-bases.ts b/apps/sim/lib/knowledge/orchestration/knowledge-bases.ts new file mode 100644 index 00000000000..b808f820e97 --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/knowledge-bases.ts @@ -0,0 +1,236 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { PlatformEvents } from '@/lib/core/telemetry' +import { generateRequestId } from '@/lib/core/utils/request' +import { DEFAULT_CHUNKING_CONFIG } from '@/lib/knowledge/constants' +import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings' +import { + auditActorFields, + classifyKnowledgeFailure, + fail, + type KnowledgeOperationContext, + type KnowledgeOrchestrationResult, +} from '@/lib/knowledge/orchestration/shared' +import { + createKnowledgeBase, + deleteKnowledgeBase, + updateKnowledgeBase, +} from '@/lib/knowledge/service' +import type { ChunkingConfig, KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { captureServerEvent } from '@/lib/posthog/server' + +const logger = createLogger('KnowledgeBaseOrchestration') + +export type PerformKnowledgeBaseResult = KnowledgeOrchestrationResult<{ + knowledgeBase: KnowledgeBaseWithCounts +}> + +export interface PerformCreateKnowledgeBaseParams extends KnowledgeOperationContext { + workspaceId: string + name: string + description?: string + /** Folder in the workspace's `knowledge_base` tree; `null`/omitted is the root. */ + folderId?: string | null + /** Omitted fields fall back to {@link DEFAULT_CHUNKING_CONFIG}. */ + chunkingConfig?: Partial +} + +/** + * Creates a knowledge base on behalf of an actor, as the single implementation + * behind the UI route, the v1 and v2 public APIs, and the copilot agent tool. + * + * The chunking default lives here rather than at each boundary: when every + * caller carried its own literal the agent's `minSize` was 1 against the API's + * 100, so identical input produced differently-chunked knowledge bases + * depending on who created it. The audit likewise lives here rather than in the + * three HTTP routes that each had their own copy — which is why an + * agent-created knowledge base used to leave no audit trail at all. + * + * The caller owns authentication; `createKnowledgeBase` still enforces the + * workspace write permission itself, and that failure comes back as `forbidden`. + */ +export async function performCreateKnowledgeBase( + params: PerformCreateKnowledgeBaseParams +): Promise { + const { workspaceId, name, description, folderId, request, source } = params + const requestId = params.requestId ?? generateRequestId() + const chunkingConfig: ChunkingConfig = { ...DEFAULT_CHUNKING_CONFIG, ...params.chunkingConfig } + const embeddingModel = getConfiguredEmbeddingModel() + + let created: KnowledgeBaseWithCounts + try { + created = await createKnowledgeBase( + { + name, + description, + workspaceId, + folderId, + userId: params.userId, + embeddingModel, + embeddingDimension: EMBEDDING_DIMENSIONS, + chunkingConfig, + }, + requestId + ) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Create knowledge base "${name}"`) + } + + logger.info(`[${requestId}] Created knowledge base ${created.id} for user ${params.userId}`) + + PlatformEvents.knowledgeBaseCreated({ + knowledgeBaseId: created.id, + name: created.name, + workspaceId, + }) + + captureServerEvent( + params.userId, + 'knowledge_base_created', + { knowledge_base_id: created.id, workspace_id: workspaceId, name: created.name }, + { + groups: { workspace: workspaceId }, + setOnce: { first_kb_created_at: new Date().toISOString() }, + } + ) + + recordAudit({ + workspaceId, + ...auditActorFields(params), + action: AuditAction.KNOWLEDGE_BASE_CREATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: created.id, + resourceName: created.name, + description: `Created knowledge base "${created.name}"`, + metadata: { + source, + name: created.name, + description: created.description, + embeddingModel, + embeddingDimension: EMBEDDING_DIMENSIONS, + chunkingStrategy: chunkingConfig.strategy, + chunkMaxSize: chunkingConfig.maxSize, + chunkMinSize: chunkingConfig.minSize, + chunkOverlap: chunkingConfig.overlap, + }, + ...(request ? { request } : {}), + }) + + return { success: true, knowledgeBase: created } +} + +export interface PerformUpdateKnowledgeBaseParams extends KnowledgeOperationContext { + knowledgeBaseId: string + /** Workspace the knowledge base currently belongs to, for the audit row. */ + workspaceId: string | null + updates: { + name?: string + description?: string + /** Moves the knowledge base between workspaces; omitted leaves it in place. */ + workspaceId?: string | null + folderId?: string | null + chunkingConfig?: ChunkingConfig + } +} + +/** + * Applies a knowledge base update and records it against the actor. + * + * `actorUserId` is always forwarded, so a workspace move is authorized against + * the caller rather than rejected for a missing actor — the v1 and v2 routes + * previously omitted it. + */ +export async function performUpdateKnowledgeBase( + params: PerformUpdateKnowledgeBaseParams +): Promise { + const { knowledgeBaseId, updates, request, source } = params + const requestId = params.requestId ?? generateRequestId() + + const updatedFields = Object.keys(updates).filter( + (key) => updates[key as keyof typeof updates] !== undefined + ) + if (updatedFields.length === 0) { + return fail('No updates specified', 'validation') + } + + let updated: KnowledgeBaseWithCounts + try { + updated = await updateKnowledgeBase(knowledgeBaseId, updates, requestId, { + actorUserId: params.userId, + }) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Update knowledge base ${knowledgeBaseId}`) + } + + logger.info(`[${requestId}] Updated knowledge base ${knowledgeBaseId}`) + + recordAudit({ + // The destination workspace when this update moved it, so the audit row + // lands where the knowledge base now lives. + workspaceId: updates.workspaceId !== undefined ? updates.workspaceId : params.workspaceId, + ...auditActorFields(params), + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: knowledgeBaseId, + resourceName: updated.name, + description: `Updated knowledge base "${updated.name}"`, + metadata: { + source, + updatedFields, + ...(updates.name && { newName: updates.name }), + ...(updates.description !== undefined && { description: updates.description }), + ...(updates.chunkingConfig && { + chunkMaxSize: updates.chunkingConfig.maxSize, + chunkMinSize: updates.chunkingConfig.minSize, + chunkOverlap: updates.chunkingConfig.overlap, + }), + }, + ...(request ? { request } : {}), + }) + + return { success: true, knowledgeBase: updated } +} + +export interface PerformDeleteKnowledgeBaseParams extends KnowledgeOperationContext { + knowledgeBase: { id: string; name: string; workspaceId: string | null } +} + +export type PerformDeleteKnowledgeBaseResult = KnowledgeOrchestrationResult + +/** + * Archives a knowledge base and its documents and connectors. + * + * The folder cascade and other internal callers keep calling + * `deleteKnowledgeBase` directly and stay silent by construction — auditing + * follows from a user performing this operation, not from the write running. + */ +export async function performDeleteKnowledgeBase( + params: PerformDeleteKnowledgeBaseParams +): Promise { + const { knowledgeBase, request, source } = params + const requestId = params.requestId ?? generateRequestId() + + try { + await deleteKnowledgeBase(knowledgeBase.id, requestId) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Delete knowledge base ${knowledgeBase.id}`) + } + + logger.info(`[${requestId}] Deleted knowledge base ${knowledgeBase.id}`) + + PlatformEvents.knowledgeBaseDeleted({ knowledgeBaseId: knowledgeBase.id }) + + recordAudit({ + workspaceId: knowledgeBase.workspaceId, + ...auditActorFields(params), + action: AuditAction.KNOWLEDGE_BASE_DELETED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: knowledgeBase.id, + resourceName: knowledgeBase.name, + description: `Deleted knowledge base "${knowledgeBase.name}"`, + metadata: { source, knowledgeBaseName: knowledgeBase.name }, + ...(request ? { request } : {}), + }) + + return { success: true } +} diff --git a/apps/sim/lib/knowledge/orchestration/restore.test.ts b/apps/sim/lib/knowledge/orchestration/restore.test.ts new file mode 100644 index 00000000000..558df668d1a --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/restore.test.ts @@ -0,0 +1,91 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRecordAudit, mockRestoreKnowledgeBase } = vi.hoisted(() => ({ + mockRecordAudit: vi.fn(), + mockRestoreKnowledgeBase: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { KNOWLEDGE_BASE_RESTORED: 'knowledge_base.restored' }, + AuditResourceType: { KNOWLEDGE_BASE: 'knowledge_base' }, + recordAudit: mockRecordAudit, +})) +vi.mock('@/lib/knowledge/service', () => ({ restoreKnowledgeBase: mockRestoreKnowledgeBase })) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { performRestoreKnowledgeBase } from '@/lib/knowledge/orchestration/restore' + +const ARCHIVED = { id: 'kb-1', name: 'Docs', workspaceId: 'ws-1', userId: 'owner' } + +describe('performRestoreKnowledgeBase', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + afterAll(resetDbChainMock) + + it('audits the restore against the acting user', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ARCHIVED]) + mockRestoreKnowledgeBase.mockResolvedValue(undefined) + + const outcome = await performRestoreKnowledgeBase({ + knowledgeBaseId: 'kb-1', + userId: 'user-1', + source: 'ui', + requestId: 'req-1', + }) + + expect(outcome).toMatchObject({ success: true }) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ actorId: 'user-1', resourceId: 'kb-1', workspaceId: 'ws-1' }) + ) + }) + + it('reports a knowledge base that is not archived as a conflict', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ARCHIVED]) + mockRestoreKnowledgeBase.mockRejectedValue( + new OrchestrationError('conflict', 'Knowledge base is not archived') + ) + + const outcome = await performRestoreKnowledgeBase({ + knowledgeBaseId: 'kb-1', + userId: 'user-1', + source: 'ui', + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'conflict' }) + }) + + it('reports bad input as validation, which the narrow local alias could not express', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ARCHIVED]) + mockRestoreKnowledgeBase.mockRejectedValue( + new OrchestrationError('validation', 'Folder not found in this workspace') + ) + + const outcome = await performRestoreKnowledgeBase({ + knowledgeBaseId: 'kb-1', + userId: 'user-1', + source: 'ui', + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + }) + + it('reports a knowledge base that does not exist as not found', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + const outcome = await performRestoreKnowledgeBase({ + knowledgeBaseId: 'kb-1', + userId: 'user-1', + source: 'ui', + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'not_found' }) + expect(mockRestoreKnowledgeBase).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/orchestration/restore.ts b/apps/sim/lib/knowledge/orchestration/restore.ts new file mode 100644 index 00000000000..c162dc160b7 --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/restore.ts @@ -0,0 +1,88 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { knowledgeBase } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { generateRequestId } from '@/lib/core/utils/request' +import { + auditActorFields, + classifyKnowledgeFailure, + fail, + type KnowledgeOperationContext, + type KnowledgeOrchestrationResult, +} from '@/lib/knowledge/orchestration/shared' +import { restoreKnowledgeBase } from '@/lib/knowledge/service' + +const logger = createLogger('KnowledgeBaseRestoreOrchestration') + +export interface RestorableKnowledgeBase { + id: string + name: string + workspaceId: string | null + userId: string +} + +export interface PerformRestoreKnowledgeBaseParams extends KnowledgeOperationContext { + knowledgeBaseId: string +} + +export type PerformRestoreKnowledgeBaseResult = KnowledgeOrchestrationResult<{ + knowledgeBase: RestorableKnowledgeBase +}> + +/** + * Loads an archived knowledge base's identity so the caller can authorize the + * restore. Reads regardless of `deletedAt` — an archived row is exactly what a + * restore targets. + */ +export async function getRestorableKnowledgeBase( + knowledgeBaseId: string +): Promise { + const [kb] = await db + .select({ + id: knowledgeBase.id, + name: knowledgeBase.name, + workspaceId: knowledgeBase.workspaceId, + userId: knowledgeBase.userId, + }) + .from(knowledgeBase) + .where(eq(knowledgeBase.id, knowledgeBaseId)) + .limit(1) + + return kb ?? null +} + +/** Un-archives a knowledge base and its documents and connectors. */ +export async function performRestoreKnowledgeBase( + params: PerformRestoreKnowledgeBaseParams +): Promise { + const { knowledgeBaseId, request, source } = params + const requestId = params.requestId ?? generateRequestId() + + const kb = await getRestorableKnowledgeBase(knowledgeBaseId) + if (!kb) { + return fail('Knowledge base not found', 'not_found') + } + + try { + await restoreKnowledgeBase(knowledgeBaseId, requestId) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Restore knowledge base ${knowledgeBaseId}`) + } + + logger.info(`[${requestId}] Restored knowledge base ${knowledgeBaseId}`) + + recordAudit({ + workspaceId: kb.workspaceId, + ...auditActorFields(params), + action: AuditAction.KNOWLEDGE_BASE_RESTORED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: knowledgeBaseId, + resourceName: kb.name, + description: `Restored knowledge base "${kb.name}"`, + metadata: { source, knowledgeBaseName: kb.name }, + ...(request ? { request } : {}), + }) + + return { success: true, knowledgeBase: kb } +} diff --git a/apps/sim/lib/knowledge/orchestration/shared.ts b/apps/sim/lib/knowledge/orchestration/shared.ts new file mode 100644 index 00000000000..2c2581deee7 --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/shared.ts @@ -0,0 +1,84 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { + asOrchestrationError, + type OrchestrationErrorCode, + type OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' + +const logger = createLogger('KnowledgeOrchestration') + +/** + * Which surface an operation came in through. Recorded in the audit metadata so + * the audit list still distinguishes a knowledge base the UI created from one an + * API key or the agent created, now that all three share one description. + */ +export type KnowledgeOperationSource = 'ui' | 'api' | 'agent' + +/** The acting user, plus the labels the audit row displays when it has them. */ +export interface KnowledgeActor { + userId: string + actorName?: string | null + actorEmail?: string | null + source: KnowledgeOperationSource +} + +/** Fields every knowledge orchestration function accepts. */ +export interface KnowledgeOperationContext extends KnowledgeActor { + requestId?: string + /** Forwarded to the audit record for IP / user-agent capture. */ + request?: OrchestrationRequestContext +} + +export interface KnowledgeOrchestrationFailure { + success: false + error: string + errorCode: OrchestrationErrorCode +} + +/** + * Every knowledge orchestration function returns this shape. A discriminated + * union rather than an all-optional record, so `if (!outcome.success) return …` + * narrows the success branch and callers reach the payload without asserting it + * is there. + */ +export type KnowledgeOrchestrationResult = + | ({ success: true } & TData) + | KnowledgeOrchestrationFailure + +export function fail( + error: string, + errorCode: OrchestrationErrorCode +): KnowledgeOrchestrationFailure { + return { success: false, error, errorCode } +} + +/** + * Maps a thrown failure to its transport-neutral class. + * + * Every caller-fixable knowledge failure is an {@link OrchestrationError} + * subclass, so the class decides the status rather than the message wording. + * Anything unclassified stays a generic 500, which is what an unexpected fault + * should be, and is logged here because no layer above will see the cause. + */ +export function classifyKnowledgeFailure( + error: unknown, + requestId: string, + operation: string +): KnowledgeOrchestrationFailure { + const classified = asOrchestrationError(error) + if (classified) { + return fail(classified.message, classified.code) + } + logger.error(`[${requestId}] ${operation} failed`, { error }) + return fail(toError(error).message, 'internal') +} + +/** The audit fields carried from the actor, omitting labels the caller lacks. */ +export function auditActorFields(actor: KnowledgeActor) { + return { + actorId: actor.userId, + ...(actor.actorName !== undefined ? { actorName: actor.actorName } : {}), + ...(actor.actorEmail !== undefined ? { actorEmail: actor.actorEmail } : {}), + } +} diff --git a/apps/sim/lib/knowledge/service.test.ts b/apps/sim/lib/knowledge/service.test.ts index 8b9ca673f2d..ce59bc0087b 100644 --- a/apps/sim/lib/knowledge/service.test.ts +++ b/apps/sim/lib/knowledge/service.test.ts @@ -71,7 +71,7 @@ describe('updateKnowledgeBase — workspace transfer authorization', () => { await expect( updateKnowledgeBase('kb-1', { workspaceId: null }, 'req-1', { actorUserId: 'attacker' }) ).rejects.toMatchObject({ - code: 'KNOWLEDGE_BASE_FORBIDDEN', + code: 'forbidden', message: 'Only the knowledge base owner can remove it from a workspace', }) expect(permissionsMockFns.mockGetUserEntityPermissions).not.toHaveBeenCalled() @@ -95,7 +95,7 @@ describe('updateKnowledgeBase — workspace transfer authorization', () => { actorUserId: 'attacker', }) ).rejects.toMatchObject({ - code: 'KNOWLEDGE_BASE_FORBIDDEN', + code: 'forbidden', message: 'User does not have permission on the target workspace', }) expect(permissionsMockFns.mockGetUserEntityPermissions).toHaveBeenCalledWith( diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index cbac1283b63..e4e8cc3707b 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -20,6 +20,7 @@ import { resolveStorageBillingContext, type StorageBillingContext, } from '@/lib/billing/storage' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRestoreName } from '@/lib/core/utils/restore-name' import { findActiveFolder, resolveRestoredFolderId } from '@/lib/folders/queries' import type { @@ -31,22 +32,39 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('KnowledgeBaseService') -export class KnowledgeBaseConflictError extends Error { - readonly code = 'KNOWLEDGE_BASE_EXISTS' as const +/** + * Every caller-fixable knowledge-base failure is an {@link OrchestrationError}, + * so `lib/knowledge/orchestration` classifies it by class and each surface maps + * that one class to its own status. Message text is then free to change without + * silently moving a 409 to a 400. + */ +export class KnowledgeBaseConflictError extends OrchestrationError { constructor(name: string) { - super(`A knowledge base named "${name}" already exists in this workspace`) + super('conflict', `A knowledge base named "${name}" already exists in this workspace`) + this.name = 'KnowledgeBaseConflictError' } } -export class KnowledgeBasePermissionError extends Error { - readonly code = 'KNOWLEDGE_BASE_FORBIDDEN' as const +export class KnowledgeBasePermissionError extends OrchestrationError { + constructor(message: string) { + super('forbidden', message) + this.name = 'KnowledgeBasePermissionError' + } } /** Raised when a caller files a knowledge base under a folder it may not use. */ -export class KnowledgeBaseFolderError extends Error { - readonly code = 'KNOWLEDGE_BASE_FOLDER_INVALID' as const +export class KnowledgeBaseFolderError extends OrchestrationError { constructor() { - super('Folder not found in this workspace') + super('validation', 'Folder not found in this workspace') + this.name = 'KnowledgeBaseFolderError' + } +} + +/** Raised when a knowledge base the caller named does not exist (or is archived). */ +export class KnowledgeBaseNotFoundError extends OrchestrationError { + constructor(knowledgeBaseId: string) { + super('not_found', `Knowledge base ${knowledgeBaseId} not found`) + this.name = 'KnowledgeBaseNotFoundError' } } @@ -341,7 +359,7 @@ export async function updateKnowledgeBase( .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) .limit(1) if (!snapshot) { - throw new Error(`Knowledge base ${knowledgeBaseId} not found`) + throw new KnowledgeBaseNotFoundError(knowledgeBaseId) } effectiveWorkspaceId = snapshot.workspaceId } @@ -365,7 +383,7 @@ export async function updateKnowledgeBase( .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) .limit(1) if (!kbSnapshot) { - throw new Error(`Knowledge base ${knowledgeBaseId} not found`) + throw new KnowledgeBaseNotFoundError(knowledgeBaseId) } const sourceWorkspaceId = kbSnapshot.workspaceId ?? null const destinationWorkspaceId = updates.workspaceId ?? null @@ -450,7 +468,7 @@ export async function updateKnowledgeBase( .limit(1) if (!currentKb) { - throw new Error(`Knowledge base ${knowledgeBaseId} not found`) + throw new KnowledgeBaseNotFoundError(knowledgeBaseId) } if (storageMove && (currentKb.workspaceId ?? null) !== storageMove.sourceWorkspaceId) { @@ -666,7 +684,7 @@ export async function updateKnowledgeBase( .limit(1) if (updatedKb.length === 0) { - throw new Error(`Knowledge base ${knowledgeBaseId} not found`) + throw new KnowledgeBaseNotFoundError(knowledgeBaseId) } logger.info(`[${requestId}] Updated knowledge base: ${knowledgeBaseId}`) @@ -809,18 +827,21 @@ export async function restoreKnowledgeBase( .limit(1) if (!kb) { - throw new Error('Knowledge base not found') + throw new KnowledgeBaseNotFoundError(knowledgeBaseId) } if (!kb.deletedAt) { - throw new Error('Knowledge base is not archived') + throw new OrchestrationError('conflict', 'Knowledge base is not archived') } if (kb.workspaceId) { const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils') const ws = await getWorkspaceWithOwner(kb.workspaceId) if (!ws || ws.archivedAt) { - throw new Error('Cannot restore knowledge base into an archived workspace') + throw new OrchestrationError( + 'conflict', + 'Cannot restore knowledge base into an archived workspace' + ) } } diff --git a/apps/sim/lib/resources/orchestration/restore-resource.ts b/apps/sim/lib/resources/orchestration/restore-resource.ts index 75baa13aaf4..ec7055f9c5f 100644 --- a/apps/sim/lib/resources/orchestration/restore-resource.ts +++ b/apps/sim/lib/resources/orchestration/restore-resource.ts @@ -148,10 +148,11 @@ export async function performRestoreResource( const result = await performRestoreKnowledgeBase({ knowledgeBaseId: id, userId, + source: 'agent', requestId, }) - if (!result.success || !result.knowledgeBase) { - return { success: false, error: result.error || 'Failed to restore knowledge base' } + if (!result.success) { + return { success: false, error: result.error } } logger.info('Knowledge base restored via restore_resource', { knowledgeBaseId: id }) From 5df4c7555f0ade6e08bbf8388d29de91e76006dc Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 11:22:12 -0700 Subject: [PATCH 22/24] feat(api): expand the public v2 files surface (#6160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): expand the public v2 files surface Adds folder support, rename/restore, move, bulk archive, share, and content replace to /api/v2/files, so managing files by API no longer stops at upload + download + archive-one. Routes are thin: auth -> parse -> perform* -> serialize. Share and content replace get their orchestration extracted first so the session routes and the public ones cannot diverge on the effective-authType resolution, the EE public-sharing gate, or the storage-quota classification. Presigned upload stays session-only: presign does an advisory quota check and the real debit happens in the separate register step, so a caller that never registers leaves unaccounted bytes with no reaper. The buffered multipart path debits inside uploadWorkspaceFile's own transaction. * fix(files): classify folder and content failures instead of 500ing them Bugbot round 1. The v2 routes map errorCode straight to a status, so every manager failure that arrived unclassified became a 500 for what is really a caller-fixable 400 or 404. - Folder manager throws OrchestrationError: missing target/folder -> not_found, reparent cycle / self-parent / restore-into-archived-workspace -> validation. - File manager does the same for the in-transaction 'File not found' paths that the earlier pass missed. - updateWorkspaceFileContent's outer catch re-wrapped everything in a bare Error, which stripped the class off StorageLimitExceededError and the new not_found alike. It now rethrows a classified failure untouched and attaches cause to the generic wrap, so asOrchestrationError can still walk the chain. - Every remaining perform* gained the asOrchestrationError branch. - renameWorkspaceFile returned the pre-update read, so the v2 PATCH reported a stale updatedAt; it now returns the timestamp it actually wrote. Docs: upload auto-suffixes a duplicate name rather than rejecting it, matching the in-app uploader. The description claimed 409 and was simply wrong. * fix(files): surface a failed upload read-back as the real error getWorkspaceFile swallows a query failure and returns null unless throwOnError is set, so a transient blip on the post-upload read reported as 'file could not be read back'. Distinguish the two: a real null after a just-committed write is an invariant break, a query failure is itself. * revert(api): drop the dedicated v2 file-folder routes File folders already live in the shared folder table as resourceType 'file' (#6045 cut them over, #6051 dropped workspace_file_folders), and the remaining file-specific folder machinery is being folded into the generic folder engine. Publishing /api/v2/files/folders/** would pin that transitional split into a public contract we'd then have to keep or break. Files stay folder-aware — folderId/folderPath on the projection, folderId on upload, and the move route — because a folder id is a folder.id and survives the unification untouched. Folder management belongs on /api/v2/folders once that surface serves resourceType 'file'; until then there is no v2 way to enumerate file folders, which is the deliberate gap. The orchestration classification fixes stay: the internal routes and the copilot file-folder tools still call those perform* functions. * fix(files): classify upload failures instead of matching their wording Bugbot round 2. uploadWorkspaceFile had the same outer-catch rewrap that updateWorkspaceFileContent did, so a blown storage quota reached the route as a bare Error and the v2 handler recovered the status by substring-matching the message. Any rewording silently demoted a 413 to a 500. - uploadWorkspaceFile rethrows a classified failure untouched and attaches cause to the generic wrap. - FileConflictError is now an OrchestrationError('conflict'), so a duplicate name classifies like every other conflict. Its 'FILE_EXISTS' discriminator had no readers and is gone; the instanceof checks elsewhere still hold. - The v2 upload handler uses v2CaughtOrchestrationError, dropping all three string matches. Also documents that bulk-archive is best-effort: unknown or already-archived ids are skipped rather than failing the call, and deletedItems is what actually happened. That asymmetry with the single-id DELETE was undocumented. --- apps/docs/openapi-v2-files-audit.json | 1281 +++++++++++++++-- apps/sim/app/api/v1/middleware.ts | 4 + .../v2/files/[fileId]/content/route.test.ts | 186 +++ .../api/v2/files/[fileId]/content/route.ts | 80 + .../v2/files/[fileId]/restore/route.test.ts | 130 ++ .../api/v2/files/[fileId]/restore/route.ts | 71 + .../app/api/v2/files/[fileId]/route.test.ts | 333 +++++ apps/sim/app/api/v2/files/[fileId]/route.ts | 72 +- .../api/v2/files/[fileId]/share/route.test.ts | 271 ++++ .../app/api/v2/files/[fileId]/share/route.ts | 130 ++ .../api/v2/files/bulk-archive/route.test.ts | 127 ++ .../app/api/v2/files/bulk-archive/route.ts | 75 + apps/sim/app/api/v2/files/move/route.test.ts | 139 ++ apps/sim/app/api/v2/files/move/route.ts | 76 + apps/sim/app/api/v2/files/route.test.ts | 322 +++++ apps/sim/app/api/v2/files/route.ts | 87 +- apps/sim/app/api/v2/files/utils.ts | 23 + .../[id]/files/[fileId]/content/route.ts | 109 +- .../[id]/files/[fileId]/share/route.ts | 176 +-- .../files/folders/[folderId]/restore/route.ts | 8 +- .../[id]/files/folders/[folderId]/route.ts | 4 +- .../workspaces/[id]/files/folders/route.ts | 8 +- apps/sim/lib/api/contracts/v2/files.ts | 276 +++- .../workspace-file-folder-manager.ts | 36 +- .../workspace/workspace-file-manager.ts | 54 +- .../workspace-files/orchestration/content.ts | 98 ++ .../file-folder-lifecycle.test.ts | 219 +++ .../orchestration/file-folder-lifecycle.ts | 60 +- .../workspace-files/orchestration/index.ts | 16 +- .../workspace-files/orchestration/share.ts | 165 +++ scripts/check-api-validation-contracts.ts | 4 +- 31 files changed, 4214 insertions(+), 426 deletions(-) create mode 100644 apps/sim/app/api/v2/files/[fileId]/content/route.test.ts create mode 100644 apps/sim/app/api/v2/files/[fileId]/content/route.ts create mode 100644 apps/sim/app/api/v2/files/[fileId]/restore/route.test.ts create mode 100644 apps/sim/app/api/v2/files/[fileId]/restore/route.ts create mode 100644 apps/sim/app/api/v2/files/[fileId]/route.test.ts create mode 100644 apps/sim/app/api/v2/files/[fileId]/share/route.test.ts create mode 100644 apps/sim/app/api/v2/files/[fileId]/share/route.ts create mode 100644 apps/sim/app/api/v2/files/bulk-archive/route.test.ts create mode 100644 apps/sim/app/api/v2/files/bulk-archive/route.ts create mode 100644 apps/sim/app/api/v2/files/move/route.test.ts create mode 100644 apps/sim/app/api/v2/files/move/route.ts create mode 100644 apps/sim/app/api/v2/files/route.test.ts create mode 100644 apps/sim/app/api/v2/files/utils.ts create mode 100644 apps/sim/lib/workspace-files/orchestration/content.ts create mode 100644 apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts create mode 100644 apps/sim/lib/workspace-files/orchestration/share.ts diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 402866bc262..81df2b36c50 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -23,7 +23,7 @@ "tags": [ { "name": "Files", - "description": "Upload, download, list, and archive workspace files (v2). Workspace-scoped via the required workspaceId query parameter." + "description": "Upload, download, list, rename, archive, restore, share, and replace the contents of workspace files (v2). Workspace-scoped via the required workspaceId query parameter or body field." }, { "name": "Audit Logs", @@ -40,7 +40,7 @@ "get": { "operationId": "listFiles", "summary": "List Files", - "description": "List the active files in a workspace with opaque cursor pagination. Results are ordered by upload time. Pass the `nextCursor` from a previous response to fetch the next page; a `null` `nextCursor` means there are no more results.", + "description": "List a workspace's files with opaque cursor pagination. Results are ordered by upload time. Pass the `nextCursor` from a previous response to fetch the next page; a `null` `nextCursor` means there are no more results. Use `scope=archived` to page through Recently Deleted — that is how you find the id of a file to restore.", "tags": ["Files"], "x-codeSamples": [ { @@ -54,6 +54,17 @@ { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "`active` (the default) lists live files; `archived` lists the ones in Recently Deleted, which is how you find an id to restore.", + "schema": { + "type": "string", + "enum": ["active", "archived"], + "default": "active" + } + }, { "name": "limit", "in": "query", @@ -97,8 +108,11 @@ "size": 1024, "type": "text/csv", "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", + "folderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", + "folderPath": "Reports/Q1", "uploadedBy": "user_abc123", - "uploadedAt": "2026-01-15T10:30:00Z" + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z" } ], "nextCursor": "eyJ1cGxvYWRlZEF0IjoiMjAyNi0wMS0xNVQxMDozMDowMFoiLCJpZCI6IndmX1YxU3RHWFI4ejVqZEhpNkJteVQ5MSJ9" @@ -126,7 +140,7 @@ "post": { "operationId": "uploadFile", "summary": "Upload File", - "description": "Upload a file to a workspace as `multipart/form-data` with a single `file` field. The workspace is supplied as the `workspaceId` query parameter (not a form field) so authorization runs before the request body is buffered. Maximum file size is 100MB. Duplicate filenames within a workspace are rejected. Returns `201 Created`.", + "description": "Upload a file to a workspace as `multipart/form-data` with a single `file` field. The workspace — and the optional target `folderId` — are supplied as query parameters (not form fields) so authorization runs before the request body is buffered. Maximum file size is 100MB. A name already taken in the destination folder is **not** an error: the name is auto-suffixed (`data.csv` -> `data (1).csv`), matching the in-app uploader, so a `201` can come back with a `name` different from the one you sent — always read `name` from the response rather than assuming it. `409` is returned only if a unique name cannot be allocated after several attempts. Use `PATCH /api/v2/files/{fileId}` if you need a specific name to be exact-or-fail. Returns `201 Created`.\n\nPresigned upload is not part of the public API: it debits the storage quota only in a separate register step, so a caller that never registers would leave unaccounted bytes in storage. This buffered path debits inside the upload transaction.", "tags": ["Files"], "x-codeSamples": [ { @@ -139,6 +153,16 @@ "parameters": [ { "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "folderId", + "in": "query", + "required": false, + "description": "Target file folder. Omit to upload to the workspace root. Supplied as a query parameter, like `workspaceId`, so authorization runs before the multipart body is buffered.", + "schema": { + "type": "string", + "example": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu" + } } ], "requestBody": { @@ -186,8 +210,11 @@ "size": 1024, "type": "text/csv", "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", + "folderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", + "folderPath": "Reports/Q1", "uploadedBy": "user_abc123", - "uploadedAt": "2026-01-15T10:30:00Z" + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z" } } } @@ -216,7 +243,7 @@ "$ref": "#/components/responses/Forbidden" }, "409": { - "description": "A file with the same name already exists in this workspace.", + "description": "A unique filename could not be allocated in the destination folder after several attempts. An ordinary name collision is auto-suffixed instead, not rejected.", "content": { "application/json": { "schema": { @@ -225,7 +252,7 @@ "example": { "error": { "code": "CONFLICT", - "message": "A file with this name already exists in the workspace" + "message": "A file named \"data.csv\" already exists in this workspace" } } } @@ -426,6 +453,112 @@ "$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.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "The new filename. Cannot contain `/`, `\\`, or be `.` / `..`.", + "example": "renamed.csv" + } + } + }, + "example": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "renamed.csv" + } + } + } + }, + "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" + }, + "example": { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "name": "renamed.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", + "folderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", + "folderPath": "Reports/Q1", + "uploadedBy": "user_abc123", + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z" + } + } + } + } + }, + "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/audit-logs": { @@ -694,115 +827,818 @@ } } } - } - }, - "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" - } - }, - "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", "uploadedBy", "uploadedAt"], - "properties": { - "id": { - "type": "string", - "description": "Unique file identifier.", - "example": "wf_V1StGXR8z5jdHi6BmyT91" + "/api/v2/files/{fileId}/restore": { + "post": { + "operationId": "restoreFile", + "summary": "Restore File", + "description": "Restore an archived file. Find archived ids with `GET /api/v2/files?scope=archived`. If the original name has since been taken by a live file, the file is restored under a suffixed name.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/restore\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "description": "The workspace that owns the file.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + } + }, + "example": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + } + } + }, + "responses": { + "200": { + "description": "The file was restored.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2RestoreFileResponse" + }, + "example": { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "restored": true + } + } + } + } }, - "name": { - "type": "string", - "description": "Original filename.", - "example": "data.csv" + "400": { + "$ref": "#/components/responses/BadRequest" }, - "size": { - "type": "integer", - "minimum": 0, - "description": "File size in bytes.", - "example": 1024 + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "type": { - "type": "string", - "description": "MIME type of the file.", - "example": "text/csv" + "403": { + "$ref": "#/components/responses/Forbidden" }, - "key": { - "type": "string", - "description": "Storage key for the file.", - "example": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv" + "404": { + "$ref": "#/components/responses/NotFound" }, - "uploadedBy": { - "type": "string", - "description": "User ID of the uploader.", - "example": "user_abc123" + "409": { + "$ref": "#/components/responses/Conflict" }, - "uploadedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp of when the file was uploaded.", - "example": "2026-01-15T10:30:00Z" - } + "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. `targetFolderId: null` — or omitting it — moves the selection to the workspace root. At least one of `fileIds` or `folderIds` 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\"], \"targetFolderId\": \"fold_9Kq2mZ7pR4tLxWc0Ye3Nu\"}'" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "description": "The workspace that owns the items.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "fileIds": { + "type": "array", + "maxItems": 1000, + "default": [], + "items": { + "type": "string" + }, + "description": "Files to move." + }, + "folderIds": { + "type": "array", + "maxItems": 1000, + "default": [], + "items": { + "type": "string" + }, + "description": "Folders to move. Descendants follow their folder." + }, + "targetFolderId": { + "type": ["string", "null"], + "description": "Destination folder. `null` or omitted moves to the workspace root.", + "example": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu" + } + } + }, + "examples": { + "intoFolder": { + "summary": "Move two files into a folder", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "fileIds": ["wf_V1StGXR8z5jdHi6BmyT91", "wf_2QrTb9xLm4PvZc7Ns1Ka"], + "targetFolderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu" + } + }, + "toRoot": { + "summary": "Move a folder back to the workspace root", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "folderIds": ["fold_9Kq2mZ7pR4tLxWc0Ye3Nu"], + "targetFolderId": null + } + } + } + } + } + }, + "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" + }, + "example": { + "data": { + "movedItems": { + "files": 2, + "folders": 0 + } + } + } + } + } + }, + "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/bulk-archive": { + "post": { + "operationId": "bulkArchiveFileItems", + "summary": "Archive Files and Folders", + "description": "Archive (soft delete) files and/or folders in one call. Archiving a folder cascades to everything under it, so `deletedItems` reports totals larger than the selection. Archived items remain listable via `scope=archived` and can be restored.\n\n**This endpoint is best-effort and idempotent.** Ids that do not exist, belong to another workspace, or are already archived are skipped rather than failing the request — the call still returns `200`. `deletedItems` is what was actually archived, so compare it against your selection if you need to detect that something was skipped. The single-item `DELETE /api/v2/files/{fileId}` does return `404` for a missing id.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\\\n \"https://www.sim.ai/api/v2/files/bulk-archive\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"fileIds\": [\"wf_V1StGXR8z5jdHi6BmyT91\"], \"folderIds\": [\"fold_9Kq2mZ7pR4tLxWc0Ye3Nu\"]}'" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "description": "The workspace that owns the items.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "fileIds": { + "type": "array", + "maxItems": 1000, + "default": [], + "items": { + "type": "string" + }, + "description": "Files to archive." + }, + "folderIds": { + "type": "array", + "maxItems": 1000, + "default": [], + "items": { + "type": "string" + }, + "description": "Folders to archive, together with their contents." + } + } + }, + "example": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "fileIds": ["wf_V1StGXR8z5jdHi6BmyT91"], + "folderIds": ["fold_9Kq2mZ7pR4tLxWc0Ye3Nu"] + } + } + } + }, + "responses": { + "200": { + "description": "The items were archived.", + "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/V2BulkArchiveFileItemsResponse" + }, + "example": { + "data": { + "deletedItems": { + "files": 3, + "folders": 1 + } + } + } + } + } + }, + "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, archive 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, archive 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.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "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." + } + } + }, + "examples": { + "text": { + "summary": "Replace with UTF-8 text", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "content": "id,name\n1,alpha\n" + } + }, + "binary": { + "summary": "Replace with base64-encoded bytes", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "content": "aWQsbmFtZQoxLGFscGhhCg==", + "encoding": "base64" + } + } + } + } + } + }, + "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" + }, + "example": { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "name": "data.csv", + "size": 16, + "type": "text/csv", + "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", + "folderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", + "folderPath": "Reports/Q1", + "uploadedBy": "user_abc123", + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T11:05:00Z" + } + } + } + } + }, + "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" + } + } + } + } + }, + "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" + } + }, + "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", + "folderId", + "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" + }, + "folderId": { + "type": ["string", "null"], + "description": "The containing file folder, or null when the file sits at the workspace root.", + "example": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu" + }, + "folderPath": { + "type": ["string", "null"], + "description": "Slash-joined folder names for `folderId`, or null at the workspace root.", + "example": "Reports/Q1" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of the last write, content or metadata.", + "example": "2026-01-15T10:30:00Z" + } } }, "V2DeleteFileResult": { @@ -993,6 +1829,201 @@ } } } + }, + "V2FileItemCounts": { + "type": "object", + "description": "Counts of what an operation actually touched. A folder cascades to its descendants, so these exceed the size of the selection.", + "required": ["files", "folders"], + "properties": { + "files": { + "type": "integer", + "description": "Number of files affected.", + "example": 3 + }, + "folders": { + "type": "integer", + "description": "Number of folders affected.", + "example": 1 + } + } + }, + "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." + } + } + }, + "V2RestoreFileResult": { + "type": "object", + "description": "Acknowledgement returned by a successful file restore.", + "required": ["id", "restored"], + "properties": { + "id": { + "type": "string", + "description": "The unique identifier of the restored file.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "restored": { + "type": "boolean", + "const": true, + "description": "Always true on a successful restore." + } + } + }, + "V2MoveFileItemsResult": { + "type": "object", + "description": "What the move actually relocated.", + "required": ["movedItems"], + "properties": { + "movedItems": { + "$ref": "#/components/schemas/V2FileItemCounts" + } + } + }, + "V2BulkArchiveFileItemsResult": { + "type": "object", + "description": "What the archive actually soft-deleted, including the cascade.", + "required": ["deletedItems"], + "properties": { + "deletedItems": { + "$ref": "#/components/schemas/V2FileItemCounts" + } + } + }, + "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" + } + } + }, + "V2RestoreFileResponse": { + "type": "object", + "description": "The result of restoring a file.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2RestoreFileResult" + } + } + }, + "V2MoveFileItemsResponse": { + "type": "object", + "description": "The result of a move.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2MoveFileItemsResult" + } + } + }, + "V2BulkArchiveFileItemsResponse": { + "type": "object", + "description": "The result of a bulk archive.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2BulkArchiveFileItemsResult" + } + } + }, + "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" + } + } } }, "responses": { @@ -1119,6 +2150,38 @@ } } } + }, + "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/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 97f7372f7a6..5fb64caf1df 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -40,6 +40,10 @@ export type ApiEndpoint = | 'table-columns' | 'files' | 'file-detail' + | 'file-share' + | 'file-content' + | 'file-move' + | 'file-bulk-archive' | 'knowledge' | 'knowledge-detail' | 'knowledge-search' 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..b2fd918c924 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts @@ -0,0 +1,186 @@ +/** + * @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', () => ({ + 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) => + PUT( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/content`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + 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('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', + folderId: null, + folderPath: null, + 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..fb310ec2e94 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.ts @@ -0,0 +1,80 @@ +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 { 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, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return 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]/restore/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/restore/route.test.ts new file mode 100644 index 00000000000..0610ef9649e --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/restore/route.test.ts @@ -0,0 +1,130 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformRestore } = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockPerformRestore: 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', () => ({ + performRestoreWorkspaceFile: mockPerformRestore, +})) + +import { POST } from '@/app/api/v2/files/[fileId]/restore/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 callRestore = (body: unknown) => + POST( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/restore`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ fileId: FILE_ID }) } + ) + +describe('POST /api/v2/files/[fileId]/restore', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformRestore.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 callRestore({ workspaceId: WS }) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockPerformRestore).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callRestore({}) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformRestore).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 callRestore({ workspaceId: WS }) + expect(res.status).toBe(403) + expect(mockPerformRestore).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callRestore({ workspaceId: WS }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('restores the file and acknowledges', async () => { + const res = await callRestore({ workspaceId: WS }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ id: FILE_ID, restored: true }) + expect(mockPerformRestore).toHaveBeenCalledWith({ + workspaceId: WS, + fileId: FILE_ID, + userId: 'user-1', + }) + }) + + it('maps a not_found errorCode to 404 rather than a blanket 500', async () => { + mockPerformRestore.mockResolvedValue({ + success: false, + error: 'File not found', + errorCode: 'not_found', + }) + + const res = await callRestore({ workspaceId: WS }) + const body = await res.json() + + expect(res.status).toBe(404) + expect(body.error.code).toBe('NOT_FOUND') + expect(body.error.message).toBe('File not found') + }) +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/restore/route.ts b/apps/sim/app/api/v2/files/[fileId]/restore/route.ts new file mode 100644 index 00000000000..0b559b9ed90 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/restore/route.ts @@ -0,0 +1,71 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2RestoreFileContract } 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 { performRestoreWorkspaceFile } 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('V2FileRestoreAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface FileRouteParams { + params: Promise<{ fileId: string }> +} + +/** + * POST /api/v2/files/[fileId]/restore — Restore an archived file. + * + * Find archived ids with `GET /api/v2/files?scope=archived`. A name collision + * with a live file is resolved by the manager's restore-name suffix, so the + * restored file may come back under a different name. + */ +export const POST = 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(v2RestoreFileContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performRestoreWorkspaceFile({ workspaceId, fileId, userId }) + + if (!result.success) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to restore file') + ) + } + + return v2Data({ id: fileId, restored: true as const }, { rateLimit }) + } catch (error) { + logger.error('Error restoring file', { 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..1afe6c6b97b --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/route.test.ts @@ -0,0 +1,333 @@ +/** + * @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', + folderId: null, + folderPath: null, + 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 index d168015d793..e85ccd4b923 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -1,18 +1,27 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' -import { v2DeleteFileContract, v2DownloadFileContract } from '@/lib/api/contracts/v2/files' +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 } from '@/lib/workspace-files/orchestration' +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 type { V2ErrorCode } from '@/app/api/v2/lib/response' import { rateLimitHeaders, v2Data, v2Error, + v2ErrorForOrchestration, v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, @@ -75,6 +84,50 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo } }) +/** + * 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] — Archive (soft delete) a file. * @@ -112,15 +165,10 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Fil }) if (!result.success) { - const code: V2ErrorCode = - result.errorCode === 'not_found' - ? 'NOT_FOUND' - : result.errorCode === 'validation' - ? 'BAD_REQUEST' - : result.errorCode === 'conflict' - ? 'CONFLICT' - : 'INTERNAL_ERROR' - return v2Error(code, result.error || 'Failed to delete file') + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to delete file') + ) } logger.info(`Archived file ${fileId} from workspace ${workspaceId}`) 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-archive/route.test.ts b/apps/sim/app/api/v2/files/bulk-archive/route.test.ts new file mode 100644 index 00000000000..9d4982abc62 --- /dev/null +++ b/apps/sim/app/api/v2/files/bulk-archive/route.test.ts @@ -0,0 +1,127 @@ +/** + * @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-archive/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 callArchive = (body: unknown) => + POST( + new NextRequest('http://localhost:3000/api/v2/files/bulk-archive', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) + +describe('POST /api/v2/files/bulk-archive', () => { + 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 callArchive({ 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 callArchive({ workspaceId: WS, fileIds: [], folderIds: [] }) + 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 callArchive({ 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 callArchive({ workspaceId: WS, fileIds: ['wf_1'] }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('archives the selection and reports the full cascade', async () => { + const res = await callArchive({ workspaceId: WS, fileIds: ['wf_1'], folderIds: ['fold_1'] }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ deletedItems: { files: 3, folders: 1 } }) + expect(mockPerformDelete).toHaveBeenCalledWith({ + workspaceId: WS, + userId: 'user-1', + fileIds: ['wf_1'], + folderIds: ['fold_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 callArchive({ 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-archive/route.ts b/apps/sim/app/api/v2/files/bulk-archive/route.ts new file mode 100644 index 00000000000..3f8ff8c256f --- /dev/null +++ b/apps/sim/app/api/v2/files/bulk-archive/route.ts @@ -0,0 +1,75 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2BulkArchiveFileItemsContract } 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('V2FileBulkArchiveAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * POST /api/v2/files/bulk-archive — Archive (soft delete) files and folders. + * + * Archiving a folder cascades to its descendants; `deletedItems` reports the + * totals actually archived, which therefore exceed the selection size. Archived + * items stay listable via `scope=archived` and can be restored. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'file-bulk-archive') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2BulkArchiveFileItemsContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, fileIds, folderIds } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performDeleteWorkspaceFileItems({ + workspaceId, + userId, + fileIds, + folderIds, + request, + }) + + if (!result.success || !result.deletedItems) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to archive file items') + ) + } + + return v2Data({ deletedItems: result.deletedItems }, { rateLimit }) + } catch (error) { + logger.error('Error archiving file items', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) 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..02c232ba4a7 --- /dev/null +++ b/apps/sim/app/api/v2/files/move/route.test.ts @@ -0,0 +1,139 @@ +/** + * @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'], + targetFolderId: 'fold_1', + }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ movedItems: { files: 2, folders: 0 } }) + expect(mockPerformMove).toHaveBeenCalledWith({ + workspaceId: WS, + userId: 'user-1', + fileIds: ['wf_1', 'wf_2'], + folderIds: [], + targetFolderId: 'fold_1', + }) + }) + + it('treats an omitted targetFolderId as the workspace root', async () => { + await callMove({ workspaceId: WS, folderIds: ['fold_2'] }) + expect(mockPerformMove).toHaveBeenCalledWith( + expect.objectContaining({ folderIds: ['fold_2'], targetFolderId: null }) + ) + }) + + 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'], targetFolderId: 'fold_1' }) + 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..bd48c7c55e7 --- /dev/null +++ b/apps/sim/app/api/v2/files/move/route.ts @@ -0,0 +1,76 @@ +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 and/or folders into a folder. + * + * `targetFolderId: null` (or an omitted field) 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, folderIds, targetFolderId } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performMoveWorkspaceFileItems({ + workspaceId, + userId, + fileIds, + folderIds, + targetFolderId: targetFolderId ?? null, + }) + + if (!result.success || !result.movedItems) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to move file items') + ) + } + + return v2Data({ movedItems: result.movedItems }, { 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..270661356a4 --- /dev/null +++ b/apps/sim/app/api/v2/files/route.test.ts @@ -0,0 +1,322 @@ +/** + * @vitest-environment node + * + * Public v2 files list/upload: gate ordering, the `scope` split that makes + * Recently Deleted reachable, and folder-targeted upload. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockListWorkspaceFiles, + mockUploadWorkspaceFile, + mockGetWorkspaceFile, + mockReadFormDataWithLimit, + mockReadFileToBufferWithLimit, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockListWorkspaceFiles: vi.fn(), + mockUploadWorkspaceFile: vi.fn(), + mockGetWorkspaceFile: vi.fn(), + mockReadFormDataWithLimit: vi.fn(), + mockReadFileToBufferWithLimit: 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', () => ({ + listWorkspaceFiles: mockListWorkspaceFiles, + uploadWorkspaceFile: mockUploadWorkspaceFile, + getWorkspaceFile: mockGetWorkspaceFile, + FileConflictError: class FileConflictError extends Error {}, +})) + +vi.mock('@/lib/core/utils/stream-limits', () => ({ + readFormDataWithLimit: mockReadFormDataWithLimit, + readFileToBufferWithLimit: mockReadFileToBufferWithLimit, + isPayloadSizeLimitError: () => false, +})) + +vi.mock('@sim/audit', () => ({ + recordAudit: vi.fn(), + AuditAction: { FILE_UPLOADED: 'file.uploaded' }, + AuditResourceType: { FILE: 'file' }, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +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, + } +} + +const callList = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/files?${query}`)) + +const callUpload = (query: string) => + POST( + new NextRequest(`http://localhost:3000/api/v2/files?${query}`, { + method: 'POST', + headers: { 'Content-Type': 'multipart/form-data; boundary=x' }, + body: 'x', + }) + ) + +describe('GET /api/v2/files', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockListWorkspaceFiles.mockResolvedValue([buildRecord()]) + }) + + 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(mockListWorkspaceFiles).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(mockListWorkspaceFiles).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(mockListWorkspaceFiles).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(mockListWorkspaceFiles).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 () => { + mockListWorkspaceFiles.mockResolvedValue([ + buildRecord({ folderId: FOLDER_ID, folderPath: 'Reports/Q1' }), + ]) + + 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', + folderId: FOLDER_ID, + folderPath: 'Reports/Q1', + uploadedBy: 'user-1', + uploadedAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + ]) + expect(mockListWorkspaceFiles).toHaveBeenCalledWith(WS, { scope: 'active' }) + }) + + it('defaults to the active scope and passes archived through', async () => { + await callList(`workspaceId=${WS}`) + expect(mockListWorkspaceFiles).toHaveBeenCalledWith(WS, { scope: 'active' }) + + const archived = buildRecord({ id: 'wf_gone', name: 'gone.csv' }) + mockListWorkspaceFiles.mockResolvedValue([archived]) + + const res = await callList(`workspaceId=${WS}&scope=archived`) + const body = await res.json() + + expect(mockListWorkspaceFiles).toHaveBeenLastCalledWith(WS, { scope: 'archived' }) + expect(body.data.map((f: { id: string }) => f.id)).toEqual(['wf_gone']) + }) +}) + +describe('POST /api/v2/files', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockReadFileToBufferWithLimit.mockResolvedValue(Buffer.from('id,name\n')) + mockUploadWorkspaceFile.mockResolvedValue({ id: 'wf_1' }) + mockGetWorkspaceFile.mockResolvedValue(buildRecord()) + + const form = new FormData() + form.set('file', new File(['id,name\n'], 'data.csv', { type: 'text/csv' })) + mockReadFormDataWithLimit.mockResolvedValue(form) + }) + + 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 callUpload(`workspaceId=${WS}`) + + expect(res.status).toBe(404) + expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callUpload('folderId=fold_1') + expect(res.status).toBe(400) + expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure before buffering the body', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callUpload(`workspaceId=${WS}`) + expect(res.status).toBe(403) + expect(mockReadFormDataWithLimit).not.toHaveBeenCalled() + expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callUpload(`workspaceId=${WS}`) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('uploads to the workspace root and returns 201 with the stored record', async () => { + const res = await callUpload(`workspaceId=${WS}`) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data.id).toBe('wf_1') + expect(body.data.folderId).toBeNull() + expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( + WS, + 'user-1', + expect.any(Buffer), + 'data.csv', + 'text/csv', + { folderId: null } + ) + }) + + it('lands the upload in the folder named by folderId', async () => { + mockGetWorkspaceFile.mockResolvedValue( + buildRecord({ folderId: FOLDER_ID, folderPath: 'Reports/Q1' }) + ) + + const res = await callUpload(`workspaceId=${WS}&folderId=${FOLDER_ID}`) + const body = await res.json() + + expect(res.status).toBe(201) + expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( + WS, + 'user-1', + expect.any(Buffer), + 'data.csv', + 'text/csv', + { folderId: FOLDER_ID } + ) + expect(body.data.folderId).toBe(FOLDER_ID) + expect(body.data.folderPath).toBe('Reports/Q1') + }) + + it('404s when the target folder does not exist', async () => { + mockUploadWorkspaceFile.mockRejectedValue( + new OrchestrationError('not_found', 'Target folder not found') + ) + + const res = await callUpload(`workspaceId=${WS}&folderId=missing`) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('413s on a blown storage quota by class, not by message wording', async () => { + mockUploadWorkspaceFile.mockRejectedValue( + new OrchestrationError('payload_too_large', 'Quota exceeded for this workspace') + ) + + const res = await callUpload(`workspaceId=${WS}`) + + expect(res.status).toBe(413) + expect((await res.json()).error.code).toBe('PAYLOAD_TOO_LARGE') + }) + + it('409s on a duplicate-name conflict by class', async () => { + mockUploadWorkspaceFile.mockRejectedValue( + new OrchestrationError('conflict', 'A file named "data.csv" already exists in this workspace') + ) + + const res = await callUpload(`workspaceId=${WS}`) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) +}) diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index 47055ab713b..fb0a0cb8bce 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -15,16 +15,17 @@ import { } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - FileConflictError, getWorkspaceFile, listWorkspaceFiles, uploadWorkspaceFile, } 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 { decodeCursor, encodeCursor, + v2CaughtOrchestrationError, v2CursorList, v2Data, v2Error, @@ -56,9 +57,13 @@ function compareFiles(a: V2File, b: V2File): number { /** * GET /api/v2/files — List files in a workspace with cursor pagination. * - * The shared {@link listWorkspaceFiles} manager returns the full active set - * ordered by `uploadedAt`; v2 applies a bounded keyset slice over that result in - * the route. Pushing `limit`/`cursor` down into the manager query is a follow-up. + * `scope=archived` reads Recently Deleted, which is what makes the restore + * endpoints usable — a caller can find the id of something it deleted. + * + * The shared {@link listWorkspaceFiles} manager returns the full set for the + * requested scope ordered by `uploadedAt`; v2 applies a bounded keyset slice + * over that result in the route. Pushing `limit`/`cursor` down into the manager + * query is a follow-up, and `scope` makes it more valuable, not less. */ export const GET = withRouteHandler(async (request: NextRequest) => { try { @@ -80,25 +85,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, limit, cursor } = parsed.data.query + const { workspaceId, scope, limit, cursor } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const files = await listWorkspaceFiles(workspaceId) - - const items: V2File[] = files - .map((f) => ({ - id: f.id, - name: f.name, - size: f.size, - type: f.type, - key: f.key, - uploadedBy: f.uploadedBy, - uploadedAt: - f.uploadedAt instanceof Date ? f.uploadedAt.toISOString() : String(f.uploadedAt), - })) - .sort(compareFiles) + const files = await listWorkspaceFiles(workspaceId, { scope }) + + const items: V2File[] = files.map(toV2File).sort(compareFiles) const decoded = cursor ? decodeCursor(cursor) : null const afterCursor = decoded @@ -126,8 +120,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { * POST /api/v2/files — Upload a file to a workspace. * * Authorization runs fully (rate limit → workspace write access) before the - * multipart body is buffered: the workspace is a contract-validated query param, - * so an unauthorized caller never streams a 100 MB body into memory. + * multipart body is buffered: the workspace and the optional target `folderId` + * are contract-validated query params, so an unauthorized caller never streams a + * 100 MB body into memory. */ export const POST = withRouteHandler(async (request: NextRequest) => { try { @@ -149,7 +144,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const { workspaceId, folderId } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -190,7 +185,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId, buffer, file.name, - file.type || 'application/octet-stream' + file.type || 'application/octet-stream', + { folderId: folderId ?? null } ) logger.info(`Uploaded file: ${file.name} to workspace ${workspaceId}`) @@ -207,38 +203,35 @@ export const POST = withRouteHandler(async (request: NextRequest) => { request, }) - const fileRecord = await getWorkspaceFile(workspaceId, userFile.id) - const uploadedAt = - fileRecord?.uploadedAt instanceof Date - ? fileRecord.uploadedAt.toISOString() - : fileRecord?.uploadedAt - ? String(fileRecord.uploadedAt) - : new Date().toISOString() - - const responseFile: V2File = { - id: userFile.id, - name: userFile.name, - size: userFile.size, - type: userFile.type, - key: userFile.key, - uploadedBy: userId, - uploadedAt, + /** + * `uploadWorkspaceFile` returns the executor-facing `UserFile`, which carries + * neither the folder path nor the persisted timestamps, so the stored record + * is the source for the response projection. + * + * `throwOnError` matters here: by default this reader swallows a query + * failure and returns `null`, which would make a transient blip on the read + * indistinguishable from the row being gone. The row was committed by the + * upload moments earlier on the same primary, so a genuine `null` is an + * invariant break — worth a 500 — while a transient failure should surface + * as itself rather than being reported as a missing file. + */ + const fileRecord = await getWorkspaceFile(workspaceId, userFile.id, { throwOnError: true }) + if (!fileRecord) { + throw new Error(`Uploaded file ${userFile.id} could not be read back`) } - return v2Data(responseFile, { rateLimit, status: 201 }) + return v2Data(toV2File(fileRecord), { rateLimit, status: 201 }) } catch (error) { if (isPayloadSizeLimitError(error)) { return v2Error('PAYLOAD_TOO_LARGE', error.message) } - const message = getErrorMessage(error, 'Failed to upload file') - if (error instanceof FileConflictError || message.includes('already exists')) { - return v2Error('CONFLICT', message) - } - if (message.includes('Storage limit') || message.includes('storage limit')) { - return v2Error('PAYLOAD_TOO_LARGE', 'Storage limit exceeded') - } + // Conflicts, a missing target folder, and a blown storage quota all arrive classified + // now, so the status comes off the error's code rather than its wording. + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + const message = getErrorMessage(error, 'Failed to upload file') logger.error('Error uploading file', { error: message }) return v2Error('INTERNAL_ERROR', 'Internal server error') } 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..c49966ce7f2 --- /dev/null +++ b/apps/sim/app/api/v2/files/utils.ts @@ -0,0 +1,23 @@ +import type { V2File } from '@/lib/api/contracts/v2/files' +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 { + return { + id: record.id, + name: record.name, + size: record.size, + type: record.type, + key: record.key, + folderId: record.folderId ?? null, + folderPath: record.folderPath ?? null, + uploadedBy: record.uploadedBy, + uploadedAt: record.uploadedAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + } +} 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..e2963b00f94 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,14 @@ -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 { 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 { performUpdateWorkspaceFileContent } from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' export const dynamic = 'force-dynamic' @@ -19,78 +21,43 @@ 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 session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const buffer = - encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf-8') + 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 maxFileSizeBytes = 50 * 1024 * 1024 - if (buffer.length > maxFileSizeBytes) { - return NextResponse.json( - { error: `File size exceeds ${maxFileSizeBytes / 1024 / 1024}MB limit` }, - { status: 413 } - ) - } + 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 updatedFile = await updateWorkspaceFileContent( - workspaceId, - fileId, - session.user.id, - buffer + 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, + }) + + if (!result.success || !result.file) { + return NextResponse.json( + { + success: false, + error: messageForOrchestrationError(result, 'Failed to update file content'), + }, + { status: statusForOrchestrationError(result.errorCode) } ) - - 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, - }) - - 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 - - if (status === 500) { - logger.error('Error updating file content:', error) - } else { - logger.warn(errorMessage) - } - - return NextResponse.json({ success: false, error: errorMessage }, { status }) } + + 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/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 040ffa4dc80..52fbeadd284 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { workspaceFileIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { shareAuthTypeSchema, shareRecordSchema } from '@/lib/api/contracts/public-shares' import { defineRouteContract } from '@/lib/api/contracts/types' import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' @@ -9,6 +10,21 @@ import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/sha * adds cursor pagination to the list. The workspace is always carried as a query * param — including on upload — so the route can authorize before reading the * multipart body. + * + * Folders are referenced but not managed here. A file carries `folderId` / + * `folderPath`, and `move` retargets it, but there are deliberately no + * folder-CRUD routes on this surface: file folders already live in the shared + * `folder` table (`resourceType: 'file'`), and the remaining file-specific + * folder machinery is being folded into the generic folder engine. Publishing + * `/api/v2/files/folders/**` would pin a transitional split into a public + * contract; folder management belongs on `/api/v2/folders` once that surface + * serves `resourceType: 'file'`. + * + * Presigned upload is deliberately absent. Presign only performs an advisory + * quota pre-check; the storage debit happens in the separate register step, so + * a caller that presigns, PUTs bytes, and never registers leaves unaccounted + * bytes in the bucket. The buffered multipart upload debits inside + * `uploadWorkspaceFile`'s own transaction, so it is the only public path. */ /** A workspace file as exposed by the v2 surface. */ @@ -18,9 +34,15 @@ export const v2FileSchema = z.object({ size: z.number().nonnegative(), type: z.string(), key: z.string(), + /** Containing file folder, or `null` when the file sits at the workspace root. */ + folderId: z.string().nullable(), + /** Slash-joined folder names for {@link v2FileSchema.folderId}; `null` at the root. */ + folderPath: z.string().nullable(), uploadedBy: z.string(), /** ISO-8601 timestamp. */ uploadedAt: z.string(), + /** ISO-8601 timestamp; advances on content and metadata writes alike. */ + updatedAt: z.string(), }) export type V2File = z.output @@ -33,12 +55,40 @@ export const v2DeleteFileResultSchema = z.object({ export type V2DeleteFileResult = z.output +/** Counts of what a cascading archive or restore touched. */ +export const v2FileItemCountsSchema = z.object({ + files: z.number().int(), + folders: z.number().int(), +}) + +export type V2FileItemCounts = z.output + export const v2FileParamsSchema = z.object({ fileId: workspaceFileIdSchema, }) export type V2FileParams = z.output +/** `active` lists live items; `archived` lists Recently Deleted. */ +export const v2FileScopeSchema = z.enum(['active', 'archived']) + +export type V2FileScope = z.output + +/** + * A file-folder name becomes a path segment, so path separators and dot + * segments are rejected rather than normalized. Mirrors + * `normalizeWorkspaceFileItemName`, which enforces the same rule in the manager. + */ +const v2FileItemNameSchema = z + .string() + .trim() + .min(1, 'name is required') + .max(255, 'name is too long') + .refine( + (name) => name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\'), + 'name cannot contain path separators or dot segments' + ) + /** * List query: workspace scope plus opaque keyset cursor pagination keyed on * `(uploadedAt, id)`. `limit` clamps to `[1, 1000]` (default 100) to bound the @@ -46,6 +96,7 @@ export type V2FileParams = z.output */ export const v2ListFilesQuerySchema = z.object({ workspaceId: workspaceIdSchema, + scope: v2FileScopeSchema.default('active'), limit: z.coerce .number() .optional() @@ -56,9 +107,15 @@ export const v2ListFilesQuerySchema = z.object({ export type V2ListFilesQuery = z.output -/** Upload carries the workspace as a query param so auth runs before buffering. */ +/** + * Upload carries the workspace as a query param so auth runs before buffering. + * `folderId` is a query param for the same reason — the multipart body is never + * read until the caller is authorized. + */ export const v2UploadFileQuerySchema = z.object({ workspaceId: workspaceIdSchema, + /** Target file folder. Omit to upload to the workspace root. */ + folderId: z.string().min(1, 'folderId cannot be empty').optional(), }) export type V2UploadFileQuery = z.output @@ -70,6 +127,148 @@ export const v2FileWorkspaceQuerySchema = z.object({ export type V2FileWorkspaceQuery = z.output +export const v2RenameFileBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: v2FileItemNameSchema, + }) + .strict() + +export type V2RenameFileBody = z.input + +export const v2WorkspaceScopedBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + }) + .strict() + +export type V2WorkspaceScopedBody = z.input + +/** A restore acknowledgement carries no payload beyond the restored id. */ +export const v2RestoreFileResultSchema = z.object({ + id: z.string(), + restored: z.literal(true), +}) + +export type V2RestoreFileResult = z.output + +const fileItemSelectionSchema = { + fileIds: z.array(z.string().min(1, 'fileIds entries cannot be empty')).max(1000).default([]), + folderIds: z.array(z.string().min(1, 'folderIds entries cannot be empty')).max(1000).default([]), +} + +export const v2MoveFileItemsBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + ...fileItemSelectionSchema, + /** Explicit `null` moves the selection to the workspace root. */ + targetFolderId: z.string().min(1, 'targetFolderId cannot be empty').nullable().optional(), + }) + .strict() + .superRefine((body, ctx) => { + if (body.fileIds.length === 0 && body.folderIds.length === 0) { + ctx.addIssue({ + code: 'custom', + path: ['fileIds'], + message: 'At least one of fileIds or folderIds must be non-empty', + }) + } + }) + +export type V2MoveFileItemsBody = z.input + +export const v2MoveFileItemsResultSchema = z.object({ + movedItems: v2FileItemCountsSchema, +}) + +export type V2MoveFileItemsResult = z.output + +export const v2BulkArchiveFileItemsBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + ...fileItemSelectionSchema, + }) + .strict() + .superRefine((body, ctx) => { + if (body.fileIds.length === 0 && body.folderIds.length === 0) { + ctx.addIssue({ + code: 'custom', + path: ['fileIds'], + message: 'At least one of fileIds or folderIds must be non-empty', + }) + } + }) + +export type V2BulkArchiveFileItemsBody = z.input + +export const v2BulkArchiveFileItemsResultSchema = z.object({ + deletedItems: v2FileItemCountsSchema, +}) + +export type V2BulkArchiveFileItemsResult = z.output + +/** + * Public share state. Reuses the internal {@link shareRecordSchema}, which is + * already public-safe — `hasPassword` is a boolean and neither the ciphertext + * nor the storage key is carried — with `url` tightened to a real URL. + */ +export const v2FileShareSchema = shareRecordSchema.extend({ + url: z.string().url(), +}) + +export type V2FileShare = z.output + +export const v2GetFileShareResultSchema = z.object({ + share: v2FileShareSchema.nullable(), +}) + +export type V2GetFileShareResult = z.output + +export const v2UpsertFileShareResultSchema = z.object({ + share: v2FileShareSchema, +}) + +export type V2UpsertFileShareResult = z.output + +/** + * Share upsert body. The internal surface accepts a caller-supplied `token` so + * the UI can show a link before saving; v2 drops it. Over an API key it would + * let a caller mint predictable public URLs, and a token collision surfaces as + * an unhandled unique-index violation. v2 tokens are always server-generated. + */ +export const v2UpsertFileShareBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + isActive: z.boolean(), + authType: shareAuthTypeSchema.optional(), + password: z + .string() + .min(1, 'password cannot be empty') + .max(1024, 'password is too long') + .optional(), + allowedEmails: z + .array(z.string().min(1, 'allowedEmails entries cannot be empty').max(320)) + .max(200, 'Too many allowed emails') + .optional(), + }) + .strict() + +export type V2UpsertFileShareBody = z.input + +/** + * Content replace body. `content` is the whole new body of the file — this is a + * replace, not an append. Base64 is the escape hatch for non-UTF-8 bytes. + */ +export const v2UpdateFileContentBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + content: z.string().max(70_000_000, 'content is too large'), + encoding: z.enum(['utf-8', 'base64']).default('utf-8'), + }) + .strict() + +export type V2UpdateFileContentBody = z.input + export const v2ListFilesContract = defineRouteContract({ method: 'GET', path: '/api/v2/files', @@ -100,6 +299,17 @@ export const v2DownloadFileContract = defineRouteContract({ }, }) +export const v2RenameFileContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/files/[fileId]', + params: v2FileParamsSchema, + body: v2RenameFileBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FileSchema), + }, +}) + export const v2DeleteFileContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/files/[fileId]', @@ -110,3 +320,67 @@ export const v2DeleteFileContract = defineRouteContract({ schema: v2DataResponse(v2DeleteFileResultSchema), }, }) + +export const v2RestoreFileContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/[fileId]/restore', + params: v2FileParamsSchema, + body: v2WorkspaceScopedBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2RestoreFileResultSchema), + }, +}) + +export const v2MoveFileItemsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/move', + body: v2MoveFileItemsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2MoveFileItemsResultSchema), + }, +}) + +export const v2BulkArchiveFileItemsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/bulk-archive', + body: v2BulkArchiveFileItemsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2BulkArchiveFileItemsResultSchema), + }, +}) + +export const v2GetFileShareContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files/[fileId]/share', + params: v2FileParamsSchema, + query: v2FileWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2GetFileShareResultSchema), + }, +}) + +export const v2UpsertFileShareContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/files/[fileId]/share', + params: v2FileParamsSchema, + body: v2UpsertFileShareBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2UpsertFileShareResultSchema), + }, +}) + +export const v2UpdateFileContentContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/files/[fileId]/content', + params: v2FileParamsSchema, + body: v2UpdateFileContentBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FileSchema), + }, +}) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index 03f58a0d3a3..ca2b80229cc 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, asc, eq, inArray, isNull, min, type SQL, sql } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { deduplicateFolderName } from '@/lib/folders/naming' import { collectDescendantFolderIds } from '@/lib/folders/subtree' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' @@ -346,7 +347,7 @@ export async function assertWorkspaceFileFolderTarget( const folder = await getWorkspaceFileFolder(workspaceId, normalized) if (!folder) { - throw new Error('Target folder not found') + throw new OrchestrationError('not_found', 'Target folder not found') } return normalized @@ -380,7 +381,7 @@ export async function createWorkspaceFileFolder(params: { .limit(1) if (!target) { - throw new Error('Target folder not found') + throw new OrchestrationError('not_found', 'Target folder not found') } } @@ -558,7 +559,7 @@ export async function updateWorkspaceFileFolder(params: { ) .limit(1) - if (!existing) throw new Error('Folder not found') + if (!existing) throw new OrchestrationError('not_found', 'Folder not found') const updates: Partial = { updatedAt: new Date() } const finalName = @@ -568,7 +569,8 @@ export async function updateWorkspaceFileFolder(params: { const finalParentId = params.parentId !== undefined ? normalizeParentId(params.parentId) : existing.parentId - if (finalParentId === params.folderId) throw new Error('Folder cannot be its own parent') + if (finalParentId === params.folderId) + throw new OrchestrationError('validation', 'Folder cannot be its own parent') if (finalParentId) { const [target] = await tx @@ -585,7 +587,7 @@ export async function updateWorkspaceFileFolder(params: { .limit(1) if (!target) { - throw new Error('Target folder not found') + throw new OrchestrationError('not_found', 'Target folder not found') } } @@ -603,7 +605,10 @@ export async function updateWorkspaceFileFolder(params: { const descendants = collectDescendantFolderIds(activeFolders, params.folderId) if (finalParentId && descendants.includes(finalParentId)) { - throw new Error('Cannot move a folder into one of its descendants') + throw new OrchestrationError( + 'validation', + 'Cannot move a folder into one of its descendants' + ) } } @@ -653,7 +658,7 @@ export async function updateWorkspaceFileFolder(params: { ) .returning() - if (!updatedFolder) throw new Error('Folder not found') + if (!updatedFolder) throw new OrchestrationError('not_found', 'Folder not found') return updatedFolder } catch (error) { if (getPostgresErrorCode(error) === '23505') { @@ -717,12 +722,12 @@ export async function moveWorkspaceFileItems(params: { .limit(1) if (!target) { - throw new Error('Target folder not found') + throw new OrchestrationError('not_found', 'Target folder not found') } } if (folderIds.includes(targetFolderId ?? '')) { - throw new Error('Cannot move a folder into itself') + throw new OrchestrationError('validation', 'Cannot move a folder into itself') } if (folderIds.length > 0) { @@ -740,7 +745,10 @@ export async function moveWorkspaceFileItems(params: { for (const folderId of folderIds) { const descendants = collectDescendantFolderIds(activeFolders, folderId) if (targetFolderId && descendants.includes(targetFolderId)) { - throw new Error('Cannot move a folder into one of its descendants') + throw new OrchestrationError( + 'validation', + 'Cannot move a folder into one of its descendants' + ) } } } @@ -891,7 +899,7 @@ export async function archiveWorkspaceFileFolderRecursive( ) .limit(1) - if (!folder) throw new Error('Folder not found') + if (!folder) throw new OrchestrationError('not_found', 'Folder not found') const activeFolders = await tx .select({ id: folderTable.id, parentId: folderTable.parentId }) @@ -944,7 +952,7 @@ export async function restoreWorkspaceFileFolder( ): Promise { const ws = await getWorkspaceWithOwner(workspaceId) if (!ws || ws.archivedAt) { - throw new Error('Cannot restore folder into an archived workspace') + throw new OrchestrationError('validation', 'Cannot restore folder into an archived workspace') } const { restored, restoredItems } = await db.transaction(async (tx) => { @@ -959,8 +967,8 @@ export async function restoreWorkspaceFileFolder( .limit(1) .then((rows) => rows[0] ?? null) - if (!raw) throw new Error('Folder not found') - if (!raw.deletedAt) throw new Error('Folder is not archived') + if (!raw) throw new OrchestrationError('not_found', 'Folder not found') + if (!raw.deletedAt) throw new OrchestrationError('validation', 'Folder is not archived') const folderDeletedAt = raw.deletedAt diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index c0dbbbecce9..c77719e6860 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -19,6 +19,7 @@ import { } from '@/lib/billing/storage' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' @@ -51,10 +52,15 @@ const logger = createLogger('WorkspaceFileStorage') export type WorkspaceFileScope = 'active' | 'archived' | 'all' -export class FileConflictError extends Error { - readonly code = 'FILE_EXISTS' as const +/** + * An {@link OrchestrationError} so every surface reaches 409 by class rather than by + * searching the message for "already exists". Carries the inherited `code: 'conflict'`; + * the old `'FILE_EXISTS'` discriminator had no readers. + */ +export class FileConflictError extends OrchestrationError { constructor(name: string) { - super(`A file named "${name}" already exists in this workspace`) + super('conflict', `A file named "${name}" already exists in this workspace`) + this.name = 'FileConflictError' } } @@ -423,8 +429,15 @@ export async function uploadWorkspaceFile( ) continue } + // A classified failure (a blown storage quota, a missing target folder) keeps its class: + // re-wrapping it in a bare Error is what forced every caller to substring-match the + // message to recover the status. + const classified = asOrchestrationError(error) + if (classified) throw classified logger.error(`Failed to upload workspace file ${fileName}:`, error) - throw new Error(`Failed to upload file: ${getErrorMessage(error, 'Unknown error')}`) + throw new Error(`Failed to upload file: ${getErrorMessage(error, 'Unknown error')}`, { + cause: error, + }) } } @@ -1072,7 +1085,7 @@ export async function updateWorkspaceFileContent( const fileRecord = await getWorkspaceFile(workspaceId, fileId) if (!fileRecord) { - throw new Error('File not found') + throw new OrchestrationError('not_found', 'File not found') } const storageBillingContext = await resolveStorageBillingContext(workspaceId) @@ -1122,7 +1135,7 @@ export async function updateWorkspaceFileContent( .for('update') .limit(1) if (!currentFile) { - throw new Error('File not found') + throw new OrchestrationError('not_found', 'File not found') } // Optimistic-concurrency guard: the row is `FOR UPDATE`-locked, so comparing its committed @@ -1169,7 +1182,7 @@ export async function updateWorkspaceFileContent( ) .returning() if (!updatedFile) { - throw new Error('File not found or could not be updated') + throw new OrchestrationError('not_found', 'File not found or could not be updated') } let updatedUsage: number | undefined @@ -1255,8 +1268,15 @@ export async function updateWorkspaceFileContent( // the optimistic-concurrency guard, not a failure to wrap. The orphan upload was already cleaned up // by the inner finalization catch before it propagated here. if (error instanceof ContentVersionConflictError) throw error + // Same reasoning for an already-classified failure: a missing file and a blown storage quota are + // caller-fixable outcomes that every surface maps to 404/413 by class. Re-wrapping them in a bare + // Error stripped that classification and turned both into a 500. + const classified = asOrchestrationError(error) + if (classified) throw classified logger.error(`Failed to update workspace file content ${fileId}:`, error) - throw new Error(`Failed to update file content: ${getErrorMessage(error, 'Unknown error')}`) + throw new Error(`Failed to update file content: ${getErrorMessage(error, 'Unknown error')}`, { + cause: error, + }) } } @@ -1275,7 +1295,7 @@ export async function renameWorkspaceFile( const fileRecord = await getWorkspaceFile(workspaceId, fileId) if (!fileRecord) { - throw new Error('File not found') + throw new OrchestrationError('not_found', 'File not found') } if (fileRecord.name === normalizedName) { @@ -1288,10 +1308,11 @@ export async function renameWorkspaceFile( } let updated: { id: string }[] + const renamedAt = new Date() try { updated = await db .update(workspaceFiles) - .set({ originalName: normalizedName, updatedAt: new Date() }) + .set({ originalName: normalizedName, updatedAt: renamedAt }) .where( and( eq(workspaceFiles.id, fileId), @@ -1308,7 +1329,7 @@ export async function renameWorkspaceFile( } if (updated.length === 0) { - throw new Error('File not found or could not be renamed') + throw new OrchestrationError('not_found', 'File not found or could not be renamed') } logger.info(`Successfully renamed workspace file ${fileId} to "${normalizedName}"`) @@ -1316,6 +1337,7 @@ export async function renameWorkspaceFile( return { ...fileRecord, name: normalizedName, + updatedAt: renamedAt, } } @@ -1336,7 +1358,7 @@ export async function moveRenameWorkspaceFile(params: { const fileRecord = await getWorkspaceFile(params.workspaceId, params.fileId) if (!fileRecord) { - throw new Error('File not found') + throw new OrchestrationError('not_found', 'File not found') } const targetFolderId = await assertWorkspaceFileFolderTarget( @@ -1376,7 +1398,7 @@ export async function moveRenameWorkspaceFile(params: { } if (updated.length === 0) { - throw new Error('File not found or could not be moved') + throw new OrchestrationError('not_found', 'File not found or could not be moved') } return { @@ -1399,7 +1421,7 @@ export async function deleteWorkspaceFile(workspaceId: string, fileId: string): try { const fileRecord = await findWorkspaceFileForLifecycle(db, workspaceId, fileId) if (!fileRecord) { - throw new Error('File not found') + throw new OrchestrationError('not_found', 'File not found') } if (fileRecord.deletedAt) return @@ -1432,7 +1454,7 @@ export async function restoreWorkspaceFile(workspaceId: string, fileId: string): const fileRecord = await findWorkspaceFileForLifecycle(db, workspaceId, fileId) if (!fileRecord) { - throw new Error('File not found') + throw new OrchestrationError('not_found', 'File not found') } if (!fileRecord.deletedAt) { @@ -1441,7 +1463,7 @@ export async function restoreWorkspaceFile(workspaceId: string, fileId: string): const ws = await getWorkspaceWithOwner(workspaceId) if (!ws || ws.archivedAt) { - throw new Error('Cannot restore file into an archived workspace') + throw new OrchestrationError('validation', 'Cannot restore file into an archived workspace') } /** diff --git a/apps/sim/lib/workspace-files/orchestration/content.ts b/apps/sim/lib/workspace-files/orchestration/content.ts new file mode 100644 index 00000000000..dacb48be300 --- /dev/null +++ b/apps/sim/lib/workspace-files/orchestration/content.ts @@ -0,0 +1,98 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { + asOrchestrationError, + type OrchestrationErrorCode, + type OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' +import { + ContentVersionConflictError, + updateWorkspaceFileContent, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace' + +const logger = createLogger('WorkspaceFileContentOrchestration') + +/** Ceiling on a single content replace, independent of the workspace quota. */ +export const MAX_WORKSPACE_FILE_CONTENT_BYTES = 50 * 1024 * 1024 + +export interface PerformUpdateWorkspaceFileContentParams { + workspaceId: string + fileId: string + userId: string + content: string + encoding: 'utf-8' | 'base64' + actorName?: string + actorEmail?: string + request?: OrchestrationRequestContext +} + +export interface PerformUpdateWorkspaceFileContentResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + file?: WorkspaceFileRecord +} + +/** + * Replaces a workspace file's bytes. + * + * Failures are classified rather than message-matched: the manager throws a + * classified `not_found`, the storage ledger throws `StorageLimitExceededError` + * (a `payload_too_large` {@link OrchestrationError}), and both reach here through + * `asOrchestrationError`, which walks the `cause` chain past drizzle's + * transaction wrapper. + */ +export async function performUpdateWorkspaceFileContent( + params: PerformUpdateWorkspaceFileContentParams +): Promise { + const { workspaceId, fileId, userId, content, encoding, actorName, actorEmail, request } = params + + const buffer = Buffer.from(content, encoding === 'base64' ? 'base64' : 'utf-8') + + if (buffer.length > MAX_WORKSPACE_FILE_CONTENT_BYTES) { + return { + success: false, + error: `File size exceeds ${MAX_WORKSPACE_FILE_CONTENT_BYTES / 1024 / 1024}MB limit`, + errorCode: 'payload_too_large', + } + } + + try { + const file = await updateWorkspaceFileContent(workspaceId, fileId, userId, buffer) + + logger.info('Updated workspace file content', { workspaceId, fileId, size: buffer.length }) + + recordAudit({ + workspaceId, + actorId: userId, + actorName, + actorEmail, + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: fileId, + resourceName: file.name, + description: `Updated content of file "${file.name}"`, + metadata: { contentSize: buffer.length }, + request, + }) + + return { success: true, file } + } catch (error) { + const classified = asOrchestrationError(error) + if (classified) { + logger.warn('Workspace file content update rejected', { + workspaceId, + fileId, + errorCode: classified.code, + }) + return { success: false, error: classified.message, errorCode: classified.code } + } + if (error instanceof ContentVersionConflictError) { + return { success: false, error: error.message, errorCode: 'conflict' } + } + logger.error('Failed to update workspace file content', { error }) + return { success: false, error: toError(error).message, errorCode: 'internal' } + } +} diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts new file mode 100644 index 00000000000..21c8be4f630 --- /dev/null +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts @@ -0,0 +1,219 @@ +/** + * @vitest-environment node + * + * Failure classification. Every `perform*` here is consumed by a public v2 route + * that maps `errorCode` straight to an HTTP status, so a manager failure that + * arrives unclassified silently becomes a 500 for what is really a caller-fixable + * 400 or 404. These pin the mapping rather than the happy paths. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockMoveWorkspaceFileItems, + mockUpdateWorkspaceFileFolder, + mockCreateWorkspaceFileFolder, + mockRestoreWorkspaceFileFolder, + mockRenameWorkspaceFile, + mockRestoreWorkspaceFile, + mockBulkArchive, +} = vi.hoisted(() => ({ + mockMoveWorkspaceFileItems: vi.fn(), + mockUpdateWorkspaceFileFolder: vi.fn(), + mockCreateWorkspaceFileFolder: vi.fn(), + mockRestoreWorkspaceFileFolder: vi.fn(), + mockRenameWorkspaceFile: vi.fn(), + mockRestoreWorkspaceFile: vi.fn(), + mockBulkArchive: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + moveWorkspaceFileItems: mockMoveWorkspaceFileItems, + updateWorkspaceFileFolder: mockUpdateWorkspaceFileFolder, + createWorkspaceFileFolder: mockCreateWorkspaceFileFolder, + restoreWorkspaceFileFolder: mockRestoreWorkspaceFileFolder, + renameWorkspaceFile: mockRenameWorkspaceFile, + restoreWorkspaceFile: mockRestoreWorkspaceFile, + bulkArchiveWorkspaceFileItems: mockBulkArchive, + moveRenameWorkspaceFile: vi.fn(), + FileConflictError: class FileConflictError extends Error {}, + WorkspaceFileFolderConflictError: class WorkspaceFileFolderConflictError extends Error {}, + WorkspaceFileMoveConflictError: class WorkspaceFileMoveConflictError extends Error {}, + WorkspaceFileItemsNotFoundError: class WorkspaceFileItemsNotFoundError extends Error {}, +})) + +vi.mock('@/lib/realtime/notify', () => ({ + notifyWorkspaceFilesChanged: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + recordAudit: vi.fn(), + AuditAction: new Proxy({}, { get: (_t, k) => String(k) }), + AuditResourceType: new Proxy({}, { get: (_t, k) => String(k) }), +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + performCreateWorkspaceFileFolder, + performMoveWorkspaceFileItems, + performRenameWorkspaceFile, + performRestoreWorkspaceFile, + performRestoreWorkspaceFileFolder, + performUpdateWorkspaceFileFolder, +} from '@/lib/workspace-files/orchestration' + +const WS = 'workspace-1' +const USER = 'user-1' + +describe('workspace file orchestration error classification', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('maps a missing move target to not_found, not internal', async () => { + mockMoveWorkspaceFileItems.mockRejectedValue( + new OrchestrationError('not_found', 'Target folder not found') + ) + + const result = await performMoveWorkspaceFileItems({ + workspaceId: WS, + userId: USER, + fileIds: ['wf_1'], + targetFolderId: 'fold_missing', + }) + + expect(result.success).toBe(false) + expect(result.errorCode).toBe('not_found') + expect(result.error).toBe('Target folder not found') + }) + + it('maps a self-descendant move to validation, not internal', async () => { + mockMoveWorkspaceFileItems.mockRejectedValue( + new OrchestrationError('validation', 'Cannot move a folder into one of its descendants') + ) + + const result = await performMoveWorkspaceFileItems({ + workspaceId: WS, + userId: USER, + folderIds: ['fold_1'], + targetFolderId: 'fold_child', + }) + + expect(result.errorCode).toBe('validation') + }) + + it('maps a folder reparent cycle to validation, not internal', async () => { + mockUpdateWorkspaceFileFolder.mockRejectedValue( + new OrchestrationError('validation', 'Cannot move a folder into one of its descendants') + ) + + const result = await performUpdateWorkspaceFileFolder({ + workspaceId: WS, + folderId: 'fold_1', + userId: USER, + parentId: 'fold_child', + }) + + expect(result.errorCode).toBe('validation') + }) + + it('maps a missing folder on update to not_found', async () => { + mockUpdateWorkspaceFileFolder.mockRejectedValue( + new OrchestrationError('not_found', 'Folder not found') + ) + + const result = await performUpdateWorkspaceFileFolder({ + workspaceId: WS, + folderId: 'fold_missing', + userId: USER, + name: 'Q2', + }) + + expect(result.errorCode).toBe('not_found') + }) + + it('maps a missing parent on create to not_found', async () => { + mockCreateWorkspaceFileFolder.mockRejectedValue( + new OrchestrationError('not_found', 'Target folder not found') + ) + + const result = await performCreateWorkspaceFileFolder({ + workspaceId: WS, + userId: USER, + name: 'Q1', + parentId: 'fold_missing', + }) + + expect(result.errorCode).toBe('not_found') + }) + + it('maps restoring into an archived workspace to validation', async () => { + mockRestoreWorkspaceFileFolder.mockRejectedValue( + new OrchestrationError('validation', 'Cannot restore folder into an archived workspace') + ) + + const result = await performRestoreWorkspaceFileFolder({ + workspaceId: WS, + folderId: 'fold_1', + userId: USER, + }) + + expect(result.errorCode).toBe('validation') + }) + + it('maps a missing file on rename to not_found', async () => { + mockRenameWorkspaceFile.mockRejectedValue(new OrchestrationError('not_found', 'File not found')) + + const result = await performRenameWorkspaceFile({ + workspaceId: WS, + fileId: 'wf_missing', + name: 'renamed.csv', + userId: USER, + }) + + expect(result.errorCode).toBe('not_found') + }) + + it('maps a missing file on restore to not_found', async () => { + mockRestoreWorkspaceFile.mockRejectedValue( + new OrchestrationError('not_found', 'File not found') + ) + + const result = await performRestoreWorkspaceFile({ + workspaceId: WS, + fileId: 'wf_missing', + userId: USER, + }) + + expect(result.errorCode).toBe('not_found') + }) + + it('classifies through a wrapper error chain, as drizzle produces inside a transaction', async () => { + const wrapped = new Error('update "folder" set ... failed', { + cause: new OrchestrationError('validation', 'Folder cannot be its own parent'), + }) + mockUpdateWorkspaceFileFolder.mockRejectedValue(wrapped) + + const result = await performUpdateWorkspaceFileFolder({ + workspaceId: WS, + folderId: 'fold_1', + userId: USER, + parentId: 'fold_1', + }) + + expect(result.errorCode).toBe('validation') + expect(result.error).toBe('Folder cannot be its own parent') + }) + + it('leaves a genuinely unexpected fault as internal', async () => { + mockRenameWorkspaceFile.mockRejectedValue(new Error('connection terminated unexpectedly')) + + const result = await performRenameWorkspaceFile({ + workspaceId: WS, + fileId: 'wf_1', + name: 'renamed.csv', + userId: USER, + }) + + expect(result.errorCode).toBe('internal') + }) +}) diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts index 7de50ed8b73..8057fb8a0c8 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts @@ -1,6 +1,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getPostgresErrorCode, toError } from '@sim/utils/errors' +import { asOrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { bulkArchiveWorkspaceFileItems, @@ -22,21 +23,6 @@ import { const logger = createLogger('WorkspaceFileFolderLifecycle') -export type WorkspaceFilesOrchestrationErrorCode = - | 'validation' - | 'not_found' - | 'conflict' - | 'internal' - -export function workspaceFilesOrchestrationStatus( - errorCode: WorkspaceFilesOrchestrationErrorCode | undefined -): number { - if (errorCode === 'validation') return 400 - if (errorCode === 'conflict') return 409 - if (errorCode === 'not_found') return 404 - return 500 -} - export interface PerformDeleteWorkspaceFileItemsParams { workspaceId: string userId: string @@ -53,7 +39,7 @@ export interface PerformDeleteWorkspaceFileItemsParams { export interface PerformDeleteWorkspaceFileItemsResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode deletedItems?: WorkspaceFileArchiveResult } @@ -68,7 +54,7 @@ export interface PerformMoveWorkspaceFileItemsParams { export interface PerformMoveWorkspaceFileItemsResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode movedItems?: { files: number; folders: number } } @@ -82,7 +68,7 @@ export interface PerformRenameWorkspaceFileParams { export interface PerformRenameWorkspaceFileResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode file?: WorkspaceFileRecord } @@ -95,7 +81,7 @@ export interface PerformRestoreWorkspaceFileParams { export interface PerformRestoreWorkspaceFileResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode } export interface PerformCreateWorkspaceFileFolderParams { @@ -108,7 +94,7 @@ export interface PerformCreateWorkspaceFileFolderParams { export interface PerformCreateWorkspaceFileFolderResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode folder?: WorkspaceFileFolderRecord } @@ -124,7 +110,7 @@ export interface PerformUpdateWorkspaceFileFolderParams { export interface PerformUpdateWorkspaceFileFolderResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode folder?: WorkspaceFileFolderRecord } @@ -137,7 +123,7 @@ export interface PerformRestoreWorkspaceFileFolderParams { export interface PerformRestoreWorkspaceFileFolderResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode folder?: WorkspaceFileFolderRecord restoredItems?: WorkspaceFileArchiveResult } @@ -207,6 +193,10 @@ export async function performDeleteWorkspaceFileItems( return { success: true, deletedItems } } catch (error) { logger.error('Failed to delete workspace file items', { error }) + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } @@ -285,6 +275,10 @@ export async function performMoveWorkspaceFileItems( if (error instanceof WorkspaceFileItemsNotFoundError) { return { success: false, error: error.message, errorCode: 'not_found' } } + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } @@ -316,6 +310,10 @@ export async function performRenameWorkspaceFile( if (error instanceof FileConflictError || getPostgresErrorCode(error) === '23505') { return { success: false, error: toError(error).message, errorCode: 'conflict' } } + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } @@ -331,7 +329,7 @@ export interface PerformMoveRenameWorkspaceFileParams { export interface PerformMoveRenameWorkspaceFileResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode file?: WorkspaceFileRecord } @@ -414,6 +412,10 @@ export async function performRestoreWorkspaceFile( if (error instanceof FileConflictError || getPostgresErrorCode(error) === '23505') { return { success: false, error: toError(error).message, errorCode: 'conflict' } } + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } @@ -448,6 +450,10 @@ export async function performCreateWorkspaceFileFolder( ) { return { success: false, error: toError(error).message, errorCode: 'conflict' } } + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } @@ -495,6 +501,10 @@ export async function performUpdateWorkspaceFileFolder( errorCode: 'conflict', } } + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } @@ -536,6 +546,10 @@ export async function performRestoreWorkspaceFileFolder( errorCode: 'conflict', } } + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } diff --git a/apps/sim/lib/workspace-files/orchestration/index.ts b/apps/sim/lib/workspace-files/orchestration/index.ts index 81c7af23352..1940e6c165c 100644 --- a/apps/sim/lib/workspace-files/orchestration/index.ts +++ b/apps/sim/lib/workspace-files/orchestration/index.ts @@ -1,3 +1,9 @@ +export { + MAX_WORKSPACE_FILE_CONTENT_BYTES, + type PerformUpdateWorkspaceFileContentParams, + type PerformUpdateWorkspaceFileContentResult, + performUpdateWorkspaceFileContent, +} from './content' export { type PerformCreateWorkspaceFileFolderParams, type PerformCreateWorkspaceFileFolderResult, @@ -23,6 +29,12 @@ export { performRestoreWorkspaceFile, performRestoreWorkspaceFileFolder, performUpdateWorkspaceFileFolder, - type WorkspaceFilesOrchestrationErrorCode, - workspaceFilesOrchestrationStatus, } from './file-folder-lifecycle' +export { + type PerformGetWorkspaceFileShareParams, + type PerformGetWorkspaceFileShareResult, + type PerformUpsertWorkspaceFileShareParams, + type PerformUpsertWorkspaceFileShareResult, + performGetWorkspaceFileShare, + performUpsertWorkspaceFileShare, +} from './share' diff --git a/apps/sim/lib/workspace-files/orchestration/share.ts b/apps/sim/lib/workspace-files/orchestration/share.ts new file mode 100644 index 00000000000..350a6d9958a --- /dev/null +++ b/apps/sim/lib/workspace-files/orchestration/share.ts @@ -0,0 +1,165 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares' +import type { + OrchestrationErrorCode, + OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' +import { + getShareForResource, + ShareValidationError, + upsertFileShare, +} from '@/lib/public-shares/share-manager' +import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { + PublicFileSharingNotAllowedError, + validatePublicFileSharing, +} from '@/ee/access-control/utils/permission-check' + +const logger = createLogger('WorkspaceFileShareOrchestration') + +export interface PerformGetWorkspaceFileShareParams { + workspaceId: string + fileId: string +} + +export interface PerformGetWorkspaceFileShareResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + share?: ShareRecord | null +} + +export interface PerformUpsertWorkspaceFileShareParams { + workspaceId: string + fileId: string + userId: string + isActive: boolean + authType?: ShareAuthType + password?: string + allowedEmails?: string[] + /** + * Caller-reserved share token. Only the session UI supplies one, so it can + * show the public link before the share is saved; the public API never does, + * because a caller-chosen token is both guessable and able to collide with an + * existing row's unique index. Omitted means the manager generates one. + */ + token?: string + actorName?: string + actorEmail?: string + request?: OrchestrationRequestContext +} + +export interface PerformUpsertWorkspaceFileShareResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + share?: ShareRecord +} + +export async function performGetWorkspaceFileShare( + params: PerformGetWorkspaceFileShareParams +): Promise { + const { workspaceId, fileId } = params + + try { + const file = await getWorkspaceFile(workspaceId, fileId) + if (!file) return { success: false, error: 'File not found', errorCode: 'not_found' } + + const share = await getShareForResource('file', fileId) + return { success: true, share } + } catch (error) { + logger.error('Failed to fetch workspace file share', { error }) + return { success: false, error: toError(error).message, errorCode: 'internal' } + } +} + +/** + * Enables or disables a file's public share. + * + * Both the access-control gate and the effective-authType resolution live here + * rather than in a route, so the session surface and the public API cannot + * diverge on either. Disabling is deliberately never gated — a user must be able + * to un-share after the org policy is turned on. + * + * Note that disabling is not revoking: {@link upsertFileShare} preserves the + * token and the stored password / allow-list, so re-enabling resurrects the + * identical URL. + */ +export async function performUpsertWorkspaceFileShare( + params: PerformUpsertWorkspaceFileShareParams +): Promise { + const { + workspaceId, + fileId, + userId, + isActive, + authType, + password, + allowedEmails, + token, + actorName, + actorEmail, + request, + } = params + + try { + const file = await getWorkspaceFile(workspaceId, fileId) + if (!file) return { success: false, error: 'File not found', errorCode: 'not_found' } + + 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(userId, workspaceId, effectiveAuthType) + } catch (error) { + if (error instanceof PublicFileSharingNotAllowedError) { + logger.warn('Public file sharing disabled for workspace', { workspaceId, fileId }) + return { success: false, error: error.message, errorCode: 'forbidden' } + } + throw error + } + } + + const share = await upsertFileShare({ + workspaceId, + fileId, + userId, + isActive, + authType, + password, + allowedEmails, + token, + }) + + logger.info(`${isActive ? 'Enabled' : 'Disabled'} share for file ${fileId}`) + + recordAudit({ + workspaceId, + actorId: userId, + actorName, + actorEmail, + 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, + }) + + return { success: true, share } + } catch (error) { + if (error instanceof ShareValidationError) { + return { success: false, error: error.message, errorCode: 'validation' } + } + logger.error('Failed to update workspace file share', { error }) + return { success: false, error: toError(error).message, errorCode: 'internal' } + } +} diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index eebf44d92e8..c52c7f59a55 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1041, - zodRoutes: 1041, + totalRoutes: 1046, + zodRoutes: 1046, nonZodRoutes: 0, } as const From 2d8350e70f0a0f4a40f275c2be113f1b5d36056d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 22:12:44 -0700 Subject: [PATCH 23/24] feat(api): add search, filtering, and sorting to the v2 list endpoints (#6189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): add search, filtering, and sorting to the v2 list endpoints One convention across every v2 list, documented on lib/api/contracts/v2/shared.ts: `search` (case-insensitive substring on the resource's natural name field), `sortBy` + `sortOrder` (per-resource enum, never a free string), and enumerated resource-specific filters. Reuses the sortBy/sortOrder pair v2 logs and v2 knowledge-documents already ship rather than inventing a third dialect alongside the Logs filters and the Tables predicate grammar. Every filter and sort is pushed into SQL. GET /api/v2/files previously read the whole scope and sorted/sliced it in JS; it now goes through a new queryWorkspaceFiles that filters, orders, and bounds the page in one query. Cursors are stamped with the sort they were minted under, so replaying one under a different sort is a 400 instead of silently duplicated or skipped rows. * fix(api): validate v2 cursor key values and compare timestamps at ms precision Two review findings, fixed at the root by making a keyset key own its cursor codec instead of hand-writing a decoder per sort. Cursor key values are caller-controlled, and matching the sort stamp and key count was not enough: an unparseable timestamp or a non-numeric size reached the query as an Invalid Date or NaN and surfaced as a 500. Each key now type- checks its own value and rejects a cursor it cannot hold, which both routes render as the documented 400. Timestamp keys now order and compare on date_trunc('milliseconds', col). Postgres keeps microseconds and defaultNow() populates them, but a cursor value round-trips through a millisecond-only JS Date — comparing the raw column against the truncated value re-admitted the page's own last row, duplicating it and stalling pagination outright at a page size of one. Reachable today via workspace_files.updated_at, which insertFileMetadata leaves to defaultNow(). --- apps/docs/openapi-v2-files-audit.json | 32 +++ apps/docs/openapi-v2-knowledge.json | 32 +++ apps/docs/openapi-v2-resources.json | 137 ++++++++++- apps/docs/openapi-v2-tables.json | 32 +++ apps/docs/openapi-v2-workflows.json | 25 ++ apps/sim/app/api/v2/credentials/route.test.ts | 27 +++ apps/sim/app/api/v2/credentials/route.ts | 5 +- .../sim/app/api/v2/custom-tools/route.test.ts | 37 ++- apps/sim/app/api/v2/custom-tools/route.ts | 4 +- apps/sim/app/api/v2/files/route.test.ts | 141 +++++++++-- apps/sim/app/api/v2/files/route.ts | 71 +++--- apps/sim/app/api/v2/folders/route.test.ts | 48 +++- apps/sim/app/api/v2/folders/route.ts | 8 +- apps/sim/app/api/v2/knowledge/route.test.ts | 135 +++++++++++ apps/sim/app/api/v2/knowledge/route.ts | 9 +- apps/sim/app/api/v2/lib/response.ts | 55 +++++ apps/sim/app/api/v2/mcp-servers/route.test.ts | 37 ++- apps/sim/app/api/v2/mcp-servers/route.ts | 4 +- apps/sim/app/api/v2/skills/route.test.ts | 31 ++- apps/sim/app/api/v2/skills/route.ts | 4 +- apps/sim/app/api/v2/tables/route.test.ts | 25 ++ apps/sim/app/api/v2/tables/route.ts | 4 +- apps/sim/app/api/v2/workflows/route.test.ts | 212 +++++++++++++++++ apps/sim/app/api/v2/workflows/route.ts | 122 ++++++---- apps/sim/lib/api/contracts/v2/credentials.ts | 14 +- apps/sim/lib/api/contracts/v2/custom-tools.ts | 21 +- apps/sim/lib/api/contracts/v2/files.ts | 26 +- apps/sim/lib/api/contracts/v2/folders.ts | 23 +- apps/sim/lib/api/contracts/v2/knowledge.ts | 28 ++- apps/sim/lib/api/contracts/v2/mcp-servers.ts | 20 +- apps/sim/lib/api/contracts/v2/shared.ts | 69 ++++++ apps/sim/lib/api/contracts/v2/skills.ts | 20 +- apps/sim/lib/api/contracts/v2/tables.ts | 31 ++- apps/sim/lib/api/contracts/v2/workflows.ts | 49 +++- apps/sim/lib/api/list-convention.test.ts | 225 ++++++++++++++++++ apps/sim/lib/api/list-query.test.ts | 193 +++++++++++++++ apps/sim/lib/api/list-query.ts | 176 ++++++++++++++ apps/sim/lib/credentials/queries.ts | 34 ++- apps/sim/lib/folders/queries.ts | 44 +++- apps/sim/lib/knowledge/service.test.ts | 2 +- apps/sim/lib/knowledge/service.ts | 50 +++- apps/sim/lib/mcp/queries.ts | 31 ++- apps/sim/lib/table/service.ts | 62 +++-- .../workspace/workspace-file-manager.ts | 158 +++++++++--- .../workspace/workspace-file-query.test.ts | 206 ++++++++++++++++ .../lib/workflows/custom-tools/operations.ts | 34 ++- apps/sim/lib/workflows/skills/operations.ts | 75 +++++- packages/testing/src/mocks/database.mock.ts | 2 + 48 files changed, 2612 insertions(+), 218 deletions(-) create mode 100644 apps/sim/app/api/v2/knowledge/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/route.test.ts create mode 100644 apps/sim/lib/api/list-convention.test.ts create mode 100644 apps/sim/lib/api/list-query.test.ts create mode 100644 apps/sim/lib/api/list-query.ts create mode 100644 apps/sim/lib/uploads/contexts/workspace/workspace-file-query.test.ts diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 81df2b36c50..476d289159e 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -79,6 +79,38 @@ }, { "$ref": "#/components/parameters/Cursor" + }, + { + "name": "folderId", + "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": { diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 806bbbccd83..38a6d2ed2f5 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -48,6 +48,38 @@ "parameters": [ { "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "folderId", + "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": { diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index d2683950f38..f7a6117a2c9 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -61,7 +61,34 @@ "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" }], + "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.", @@ -405,7 +432,34 @@ "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" }], + "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.", @@ -706,7 +760,34 @@ "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" }], + "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.", @@ -1057,6 +1138,31 @@ "required": false, "description": "`active` (default) lists live folders; `archived` lists Recently Deleted.", "schema": { "type": "string", "enum": ["active", "archived"], "default": "active" } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the folder `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 tree's own manual arrangement, which is the default order.", + "schema": { + "type": "string", + "enum": ["position", "name", "createdAt", "updatedAt"], + "default": "position" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" } } ], "responses": { @@ -1407,6 +1513,31 @@ "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": { diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 3f50df8b4b0..00f523d23eb 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -49,6 +49,38 @@ "parameters": [ { "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "folderId", + "in": "query", + "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" } } ], "responses": { diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 95b85a369f2..0f7cb0a95b6 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -90,6 +90,31 @@ "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": { diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index d9fa30535a9..2bfe1cbfad4 100644 --- a/apps/sim/app/api/v2/credentials/route.test.ts +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -204,6 +204,33 @@ describe('GET /api/v2/credentials', () => { 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', () => { diff --git a/apps/sim/app/api/v2/credentials/route.ts b/apps/sim/app/api/v2/credentials/route.ts index 232110187c1..2b710b275bf 100644 --- a/apps/sim/app/api/v2/credentials/route.ts +++ b/apps/sim/app/api/v2/credentials/route.ts @@ -54,7 +54,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, type, providerId } = parsed.data.query + const { workspaceId, type, providerId, search, sortBy, sortOrder } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -71,6 +71,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { workspaceAccess, type, providerId, + search, + sortBy, + sortOrder, }) // The per-workspace credential set is small and bounded → a single full page. diff --git a/apps/sim/app/api/v2/custom-tools/route.test.ts b/apps/sim/app/api/v2/custom-tools/route.test.ts index 5693e018448..7ca46e81b0c 100644 --- a/apps/sim/app/api/v2/custom-tools/route.test.ts +++ b/apps/sim/app/api/v2/custom-tools/route.test.ts @@ -82,6 +82,13 @@ function buildTool(overrides: Record = {}) { } } +/** 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}`)) @@ -162,7 +169,35 @@ describe('GET /api/v2/custom-tools', () => { updatedAt: '2024-01-02T00:00:00.000Z', }, ]) - expect(mockListWorkspaceCustomTools).toHaveBeenCalledWith({ workspaceId: 'workspace-1' }) + 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() }) }) diff --git a/apps/sim/app/api/v2/custom-tools/route.ts b/apps/sim/app/api/v2/custom-tools/route.ts index b746b285cc2..96b678a5078 100644 --- a/apps/sim/app/api/v2/custom-tools/route.ts +++ b/apps/sim/app/api/v2/custom-tools/route.ts @@ -52,12 +52,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + 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 }) + 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 }) diff --git a/apps/sim/app/api/v2/files/route.test.ts b/apps/sim/app/api/v2/files/route.test.ts index 270661356a4..c6e68913959 100644 --- a/apps/sim/app/api/v2/files/route.test.ts +++ b/apps/sim/app/api/v2/files/route.test.ts @@ -10,7 +10,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockCheckRateLimit, mockResolveWorkspaceAccess, - mockListWorkspaceFiles, + mockQueryWorkspaceFiles, mockUploadWorkspaceFile, mockGetWorkspaceFile, mockReadFormDataWithLimit, @@ -18,7 +18,7 @@ const { } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceAccess: vi.fn(), - mockListWorkspaceFiles: vi.fn(), + mockQueryWorkspaceFiles: vi.fn(), mockUploadWorkspaceFile: vi.fn(), mockGetWorkspaceFile: vi.fn(), mockReadFormDataWithLimit: vi.fn(), @@ -35,7 +35,7 @@ vi.mock('@/app/api/v2/lib/gate', () => ({ })) vi.mock('@/lib/uploads/contexts/workspace', () => ({ - listWorkspaceFiles: mockListWorkspaceFiles, + queryWorkspaceFiles: mockQueryWorkspaceFiles, uploadWorkspaceFile: mockUploadWorkspaceFile, getWorkspaceFile: mockGetWorkspaceFile, FileConflictError: class FileConflictError extends Error {}, @@ -94,6 +94,17 @@ function buildRecord(overrides: Record = {}) { } } +/** What the route forwards for a bare `?workspaceId=` list. */ +const DEFAULT_LIST_ARGS = { + scope: 'active', + 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}`)) @@ -111,7 +122,7 @@ describe('GET /api/v2/files', () => { vi.clearAllMocks() mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) mockResolveWorkspaceAccess.mockResolvedValue(null) - mockListWorkspaceFiles.mockResolvedValue([buildRecord()]) + mockQueryWorkspaceFiles.mockResolvedValue({ files: [buildRecord()], nextKeys: null }) }) it('returns 404 when the v2 API surface flag is off', async () => { @@ -123,20 +134,20 @@ describe('GET /api/v2/files', () => { expect(res.status).toBe(404) expect((await res.json()).error.code).toBe('NOT_FOUND') - expect(mockListWorkspaceFiles).not.toHaveBeenCalled() + 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(mockListWorkspaceFiles).not.toHaveBeenCalled() + 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(mockListWorkspaceFiles).not.toHaveBeenCalled() + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() }) it('surfaces an access-denied failure in the v2 error envelope', async () => { @@ -147,7 +158,7 @@ describe('GET /api/v2/files', () => { }) const res = await callList(`workspaceId=${WS}`) expect(res.status).toBe(403) - expect(mockListWorkspaceFiles).not.toHaveBeenCalled() + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() }) it('returns the rate-limit response when denied', async () => { @@ -158,9 +169,10 @@ describe('GET /api/v2/files', () => { }) it('returns the public file shape including folder and updatedAt', async () => { - mockListWorkspaceFiles.mockResolvedValue([ - buildRecord({ folderId: FOLDER_ID, folderPath: 'Reports/Q1' }), - ]) + mockQueryWorkspaceFiles.mockResolvedValue({ + files: [buildRecord({ folderId: FOLDER_ID, folderPath: 'Reports/Q1' })], + nextKeys: null, + }) const res = await callList(`workspaceId=${WS}`) const body = await res.json() @@ -181,22 +193,121 @@ describe('GET /api/v2/files', () => { updatedAt: '2024-01-02T00:00:00.000Z', }, ]) - expect(mockListWorkspaceFiles).toHaveBeenCalledWith(WS, { scope: 'active' }) + expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, DEFAULT_LIST_ARGS) }) it('defaults to the active scope and passes archived through', async () => { await callList(`workspaceId=${WS}`) - expect(mockListWorkspaceFiles).toHaveBeenCalledWith(WS, { scope: 'active' }) + expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, DEFAULT_LIST_ARGS) const archived = buildRecord({ id: 'wf_gone', name: 'gone.csv' }) - mockListWorkspaceFiles.mockResolvedValue([archived]) + mockQueryWorkspaceFiles.mockResolvedValue({ files: [archived], nextKeys: null }) const res = await callList(`workspaceId=${WS}&scope=archived`) const body = await res.json() - expect(mockListWorkspaceFiles).toHaveBeenLastCalledWith(WS, { scope: 'archived' }) + expect(mockQueryWorkspaceFiles).toHaveBeenLastCalledWith(WS, { + ...DEFAULT_LIST_ARGS, + scope: 'archived', + }) expect(body.data.map((f: { id: string }) => f.id)).toEqual(['wf_gone']) }) + + it('forwards search, folder, and sort into the query rather than filtering the result', async () => { + await callList( + `workspaceId=${WS}&search=report&folderId=${FOLDER_ID}&sortBy=name&sortOrder=desc` + ) + + expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, { + ...DEFAULT_LIST_ARGS, + folderId: FOLDER_ID, + search: 'report', + sortBy: 'name', + sortOrder: 'desc', + }) + }) + + 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', () => { diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index fb0a0cb8bce..2c088b8b611 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -16,17 +16,19 @@ import { import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getWorkspaceFile, - listWorkspaceFiles, + queryWorkspaceFiles, uploadWorkspaceFile, } 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 { - decodeCursor, - encodeCursor, + cursorSortKey, + decodeSortedCursor, + encodeSortedCursor, v2CaughtOrchestrationError, v2CursorList, + v2CursorSortError, v2Data, v2Error, v2RateLimitError, @@ -42,28 +44,16 @@ export const revalidate = 0 const MAX_FILE_SIZE = 100 * 1024 * 1024 const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 -interface FileCursor { - uploadedAt: string - id: string -} - -/** Stable keyset ordering: `uploadedAt` ascending, `id` ascending as the tiebreaker. */ -function compareFiles(a: V2File, b: V2File): number { - if (a.uploadedAt !== b.uploadedAt) return a.uploadedAt < b.uploadedAt ? -1 : 1 - if (a.id !== b.id) return a.id < b.id ? -1 : 1 - return 0 -} - /** - * GET /api/v2/files — List files in a workspace with cursor pagination. + * GET /api/v2/files — List files in a workspace with search, sort, and cursor + * pagination. * * `scope=archived` reads Recently Deleted, which is what makes the restore * endpoints usable — a caller can find the id of something it deleted. * - * The shared {@link listWorkspaceFiles} manager returns the full set for the - * requested scope ordered by `uploadedAt`; v2 applies a bounded keyset slice - * over that result in the route. Pushing `limit`/`cursor` down into the manager - * query is a follow-up, and `scope` makes it more valuable, not less. + * 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 { @@ -85,32 +75,35 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, scope, limit, cursor } = parsed.data.query + const { workspaceId, scope, folderId, search, sortBy, sortOrder, limit, cursor } = + parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const files = await listWorkspaceFiles(workspaceId, { scope }) - - const items: V2File[] = files.map(toV2File).sort(compareFiles) - - const decoded = cursor ? decodeCursor(cursor) : null - const afterCursor = decoded - ? items.filter( - (f) => - f.uploadedAt > decoded.uploadedAt || - (f.uploadedAt === decoded.uploadedAt && f.id > decoded.id) - ) - : items + const sort = cursorSortKey(sortBy, sortOrder) + const decoded = decodeSortedCursor(cursor, sort) + if (decoded.status === 'invalid') return v2CursorSortError() + + const { files, nextKeys } = await queryWorkspaceFiles(workspaceId, { + scope, + folderId, + search, + sortBy, + sortOrder, + limit, + after: decoded.status === 'ok' ? decoded.keys : undefined, + }) - const hasMore = afterCursor.length > limit - const page = afterCursor.slice(0, limit) - const last = page.at(-1) - const nextCursor = - hasMore && last ? encodeCursor({ uploadedAt: last.uploadedAt, id: last.id }) : null + const items: V2File[] = files.map(toV2File) + const nextCursor = nextKeys ? encodeSortedCursor(sort, nextKeys) : null - return v2CursorList(page, nextCursor, { rateLimit }) + 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') } diff --git a/apps/sim/app/api/v2/folders/route.test.ts b/apps/sim/app/api/v2/folders/route.test.ts index 85399484b5b..4f2622ee903 100644 --- a/apps/sim/app/api/v2/folders/route.test.ts +++ b/apps/sim/app/api/v2/folders/route.test.ts @@ -95,6 +95,13 @@ function buildRow(overrides: Record = {}) { } } +/** What the route forwards for a bare `?workspaceId=` list. */ +const DEFAULT_LIST_ARGS = { + search: undefined, + sortBy: 'position', + sortOrder: 'asc', +} + const callList = (query: string) => GET(new NextRequest(`http://localhost:3000/api/v2/folders?${query}`)) @@ -189,12 +196,49 @@ describe('GET /api/v2/folders', () => { deletedAt: null, }, ]) - expect(mockListFoldersForWorkspace).toHaveBeenCalledWith('workspace-1', 'active', 'workflow') + expect(mockListFoldersForWorkspace).toHaveBeenCalledWith( + 'workspace-1', + 'active', + 'workflow', + DEFAULT_LIST_ARGS + ) }) it('passes the archived scope through', async () => { await callList('workspaceId=workspace-1&resourceType=table&scope=archived') - expect(mockListFoldersForWorkspace).toHaveBeenCalledWith('workspace-1', 'archived', 'table') + expect(mockListFoldersForWorkspace).toHaveBeenCalledWith( + 'workspace-1', + 'archived', + 'table', + 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&resourceType=workflow&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&resourceType=workflow&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&resourceType=workflow&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&resourceType=workflow&search=report&sortBy=name&sortOrder=asc` + ) + + expect(res.status).toBe(200) + expect((await res.json()).nextCursor).toBeNull() }) }) diff --git a/apps/sim/app/api/v2/folders/route.ts b/apps/sim/app/api/v2/folders/route.ts index 2ce758499b4..ed74572a617 100644 --- a/apps/sim/app/api/v2/folders/route.ts +++ b/apps/sim/app/api/v2/folders/route.ts @@ -47,12 +47,16 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, resourceType, scope } = parsed.data.query + const { workspaceId, resourceType, scope, search, sortBy, sortOrder } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const folders = await listFoldersForWorkspace(workspaceId, scope, resourceType) + const folders = await listFoldersForWorkspace(workspaceId, scope, resourceType, { + search, + sortBy, + sortOrder, + }) // One workspace's tree for one resource type is bounded → a single full page. return v2CursorList(folders.map(toV2FolderFromApi), null, { 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..2b6d8f635a6 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/route.test.ts @@ -0,0 +1,135 @@ +/** + * @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 } = vi.hoisted( + () => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetKnowledgeBases: vi.fn(), + }) +) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/knowledge/service', () => ({ + getKnowledgeBases: mockGetKnowledgeBases, +})) + +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()]) + }) + + it('forwards search, folder, and sort into the query rather than filtering the result', async () => { + const res = await callList( + `workspaceId=${WS}&search=support&folderId=${FOLDER_ID}&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('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 index 64e6445687b..01811a343f5 100644 --- a/apps/sim/app/api/v2/knowledge/route.ts +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -51,12 +51,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const { workspaceId, folderId, search, sortBy, sortOrder } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const knowledgeBases = await getKnowledgeBases(userId, workspaceId) + const knowledgeBases = await getKnowledgeBases(userId, workspaceId, 'active', { + folderId, + search, + sortBy, + sortOrder, + }) const items = knowledgeBases.map(formatKnowledgeBase) // `getKnowledgeBases` returns the full bounded workspace set → single page. diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index 3bdc2b90b91..f226e77a345 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -1,5 +1,6 @@ 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' @@ -157,6 +158,60 @@ export function decodeCursor>(cursor: string): T | n } } +/** + * 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', diff --git a/apps/sim/app/api/v2/mcp-servers/route.test.ts b/apps/sim/app/api/v2/mcp-servers/route.test.ts index f9893f60c8e..cb0df3ac683 100644 --- a/apps/sim/app/api/v2/mcp-servers/route.test.ts +++ b/apps/sim/app/api/v2/mcp-servers/route.test.ts @@ -100,6 +100,13 @@ function callCreate(body: unknown) { ) } +/** 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', @@ -187,7 +194,10 @@ describe('GET /api/v2/mcp-servers', () => { hasOauthClientSecret: false, }, ]) - expect(mockListWorkspaceMcpServers).toHaveBeenCalledWith({ workspaceId: 'workspace-1' }) + expect(mockListWorkspaceMcpServers).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + ...DEFAULT_LIST_ARGS, + }) }) it('never returns configured header values', async () => { @@ -197,6 +207,31 @@ describe('GET /api/v2/mcp-servers', () => { 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', () => { diff --git a/apps/sim/app/api/v2/mcp-servers/route.ts b/apps/sim/app/api/v2/mcp-servers/route.ts index 73a18037501..e9a32f7251f 100644 --- a/apps/sim/app/api/v2/mcp-servers/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/route.ts @@ -55,12 +55,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + 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 }) + 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 }) diff --git a/apps/sim/app/api/v2/skills/route.test.ts b/apps/sim/app/api/v2/skills/route.test.ts index 6cf0ae6f52f..8e1c5131c2e 100644 --- a/apps/sim/app/api/v2/skills/route.test.ts +++ b/apps/sim/app/api/v2/skills/route.test.ts @@ -147,7 +147,36 @@ describe('GET /api/v2/skills', () => { updatedAt: '2024-01-02T00:00:00.000Z', }, ]) - expect(mockListSkills).toHaveBeenCalledWith({ workspaceId: 'workspace-1' }) + 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() }) }) diff --git a/apps/sim/app/api/v2/skills/route.ts b/apps/sim/app/api/v2/skills/route.ts index 5e1d6a825b2..1541ca2be7f 100644 --- a/apps/sim/app/api/v2/skills/route.ts +++ b/apps/sim/app/api/v2/skills/route.ts @@ -47,12 +47,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + 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 }) + 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 }) diff --git a/apps/sim/app/api/v2/tables/route.test.ts b/apps/sim/app/api/v2/tables/route.test.ts index af7a12f403a..f13c00e3027 100644 --- a/apps/sim/app/api/v2/tables/route.test.ts +++ b/apps/sim/app/api/v2/tables/route.test.ts @@ -136,4 +136,29 @@ describe('GET /api/v2/tables', () => { 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((await res.json()).nextCursor).toBeNull() + }) }) diff --git a/apps/sim/app/api/v2/tables/route.ts b/apps/sim/app/api/v2/tables/route.ts index 85df923214c..1fcafeff9fd 100644 --- a/apps/sim/app/api/v2/tables/route.ts +++ b/apps/sim/app/api/v2/tables/route.ts @@ -49,12 +49,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const { workspaceId, folderId, search, sortBy, sortOrder } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const tables = await listTables(workspaceId) + const tables = await listTables(workspaceId, { folderId, search, sortBy, sortOrder }) const items = tables.map(toApiTable) // `listTables` returns the full bounded workspace set → single page. 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..5c2b922e40b --- /dev/null +++ b/apps/sim/app/api/v2/workflows/route.test.ts @@ -0,0 +1,212 @@ +/** + * @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 } = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET } 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) + }) + + 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('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() + }) +}) diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts index ffe19c9ebf1..0706f835c53 100644 --- a/apps/sim/app/api/v2/workflows/route.ts +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -3,17 +3,34 @@ import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, asc, eq, gt, isNull, or } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { type V2WorkflowListItem, v2ListWorkflowsContract } from '@/lib/api/contracts/v2/workflows' +import { + type V2WorkflowListItem, + type V2WorkflowSortBy, + 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 { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { - decodeCursor, - encodeCursor, + cursorSortKey, + decodeSortedCursor, + encodeSortedCursor, v2CursorList, + v2CursorSortError, v2Error, v2RateLimitError, v2ValidationError, @@ -25,13 +42,40 @@ const logger = createLogger('V2WorkflowsAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -/** Keyset cursor for the `(sortOrder, createdAt, id)` ordering. */ -interface WorkflowListCursor { - sortOrder: number - createdAt: string +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) @@ -59,36 +103,24 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const conditions = [eq(workflow.workspaceId, params.workspaceId), isNull(workflow.archivedAt)] - - if (params.folderId) { - conditions.push(eq(workflow.folderId, params.folderId)) - } - - if (params.deployedOnly) { - conditions.push(eq(workflow.isDeployed, true)) - } - - if (params.cursor) { - const cursorData = decodeCursor(params.cursor) - if (cursorData) { - const cursorCondition = or( - gt(workflow.sortOrder, cursorData.sortOrder), - and( - eq(workflow.sortOrder, cursorData.sortOrder), - gt(workflow.createdAt, new Date(cursorData.createdAt)) - ), - and( - eq(workflow.sortOrder, cursorData.sortOrder), - eq(workflow.createdAt, new Date(cursorData.createdAt)), - gt(workflow.id, cursorData.id) - ) - ) - if (cursorCondition) { - conditions.push(cursorCondition) - } - } - } + 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.folderId ? eq(workflow.folderId, params.folderId) : undefined, + params.deployedOnly ? eq(workflow.isDeployed, true) : undefined, + searchFilter(workflow.name, params.search), + resumeAfter, + ] const rows = await db .select({ @@ -107,21 +139,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) .from(workflow) .where(and(...conditions)) - .orderBy(asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)) + .orderBy(...listOrderBy(keysetColumns(keys), params.sortOrder)) .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 last = data[data.length - 1] - nextCursor = encodeCursor({ - sortOrder: last.sortOrder, - createdAt: last.createdAt.toISOString(), - id: last.id, - }) - } + const last = data.at(-1) + const nextCursor = + hasMore && last ? encodeSortedCursor(sortKey, encodeKeyset(keys, last)) : null const formatted: V2WorkflowListItem[] = data.map((w) => ({ id: w.id, diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index 205dd794bb7..7c411ec532d 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -6,7 +6,12 @@ import { } from '@/lib/api/contracts/credentials' import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' import { getServiceAccountRequiredFields } from '@/lib/credentials/service-account-fields' /** @@ -78,9 +83,16 @@ export const v2CredentialWorkspaceQuerySchema = z.object({ }) export type V2CredentialWorkspaceQuery = z.output +/** A credential's natural name field is `displayName`, so that is what `search` matches. */ +export const v2CredentialSortFields = ['displayName', 'createdAt', 'updatedAt'] as const + +export type V2CredentialSortBy = (typeof v2CredentialSortFields)[number] + export const v2ListCredentialsQuerySchema = v2CredentialWorkspaceQuerySchema.extend({ type: workspaceCredentialTypeSchema.optional(), providerId: z.string().min(1, 'providerId cannot be empty').optional(), + search: v2SearchSchema, + ...v2SortFields(v2CredentialSortFields, { sortBy: 'createdAt', sortOrder: 'desc' }), }) export type V2ListCredentialsQuery = z.output diff --git a/apps/sim/lib/api/contracts/v2/custom-tools.ts b/apps/sim/lib/api/contracts/v2/custom-tools.ts index 7082c6d1c82..c2e6221e776 100644 --- a/apps/sim/lib/api/contracts/v2/custom-tools.ts +++ b/apps/sim/lib/api/contracts/v2/custom-tools.ts @@ -2,7 +2,12 @@ import { z } from 'zod' import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { customToolSchemaSchema } from '@/lib/api/contracts/tools/custom' import { defineRouteContract } from '@/lib/api/contracts/types' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' /** * v2 custom tool contracts. @@ -59,6 +64,18 @@ export const v2CustomToolWorkspaceQuerySchema = z.object({ }) export type V2CustomToolWorkspaceQuery = z.output +/** A custom tool's natural name field is `title`, so that is what `search` matches. */ +export const v2CustomToolSortFields = ['title', 'createdAt', 'updatedAt'] as const + +export type V2CustomToolSortBy = (typeof v2CustomToolSortFields)[number] + +export const v2ListCustomToolsQuerySchema = v2CustomToolWorkspaceQuerySchema.extend({ + search: v2SearchSchema, + ...v2SortFields(v2CustomToolSortFields, { sortBy: 'createdAt', sortOrder: 'desc' }), +}) + +export type V2ListCustomToolsQuery = z.output + export const v2CreateCustomToolBodySchema = z .object({ workspaceId: workspaceIdSchema, @@ -97,7 +114,7 @@ export type V2UpdateCustomToolBody = z.input, id)`, so the cursor is stamped with the sort it was + * minted under and rejected if the request's sort has since changed. Filtering, + * ordering, and the page slice all happen in the query. */ export const v2ListFilesQuerySchema = z.object({ workspaceId: workspaceIdSchema, scope: v2FileScopeSchema.default('active'), + /** Restrict to one file folder. Omit to list the whole workspace. */ + folderId: z.string().min(1, 'folderId cannot be empty').optional(), + search: v2SearchSchema, + ...v2SortFields(v2FileSortFields, { sortBy: 'uploadedAt', sortOrder: 'asc' }), limit: z.coerce .number() .optional() diff --git a/apps/sim/lib/api/contracts/v2/folders.ts b/apps/sim/lib/api/contracts/v2/folders.ts index 6f12fe25637..e59abdbdab1 100644 --- a/apps/sim/lib/api/contracts/v2/folders.ts +++ b/apps/sim/lib/api/contracts/v2/folders.ts @@ -7,7 +7,12 @@ import { } from '@/lib/api/contracts/folders' import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' /** * v2 folder contracts. @@ -73,9 +78,25 @@ export const v2FolderScopedQuerySchema = z.object({ }) export type V2FolderScopedQuery = z.output +/** + * Sortable folder fields. `position` is the tree's manual arrangement (the + * `sort_order` column), kept as the default so a bare list still comes back in + * the order the workspace arranged it. + */ +export const v2FolderSortFields = ['position', 'name', 'createdAt', 'updatedAt'] as const + +export type V2FolderSortBy = (typeof v2FolderSortFields)[number] + +/** + * List query. `search` narrows to folders whose name matches; the result stays + * a flat list either way, so a matching folder is returned without its + * ancestors — reconstruct a tree from `parentId` only on an unsearched list. + */ export const v2ListFoldersQuerySchema = v2FolderScopedQuerySchema.extend({ /** `active` (default) lists live folders; `archived` lists Recently Deleted. */ scope: folderScopeSchema.default('active'), + search: v2SearchSchema, + ...v2SortFields(v2FolderSortFields, { sortBy: 'position', sortOrder: 'asc' }), }) export type V2ListFoldersQuery = z.output diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index 06f4064d2fb..d92f30c20a1 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -16,7 +16,12 @@ import { v1ListKnowledgeDocumentsQuerySchema, v1UpdateKnowledgeBaseBodySchema, } from '@/lib/api/contracts/v1/knowledge' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' /** * v2 knowledge contracts. @@ -146,16 +151,35 @@ export type V2KnowledgeSearchData = z.output export const v2UploadKnowledgeDocumentQuerySchema = z.object({ workspaceId: workspaceIdSchema }) export type V2UploadKnowledgeDocumentQuery = z.output +export const v2KnowledgeBaseSortFields = ['name', 'createdAt', 'updatedAt'] as const + +export type V2KnowledgeBaseSortBy = (typeof v2KnowledgeBaseSortFields)[number] + +/** + * KB list query: v1's workspace scope plus the v2 search/sort convention and a + * folder filter. v1's own list query stays untouched — it does not implement + * these, and advertising a param a route ignores is worse than not having it. + */ +export const v2ListKnowledgeBasesQuerySchema = v1ListKnowledgeBasesQuerySchema.extend({ + /** Restrict to one knowledge-base folder. */ + folderId: z.string().min(1, 'folderId cannot be empty').optional(), + search: v2SearchSchema, + ...v2SortFields(v2KnowledgeBaseSortFields, { sortBy: 'createdAt', sortOrder: 'asc' }), +}) + +export type V2ListKnowledgeBasesQuery = z.output + /** * KB list. `getKnowledgeBases` returns the full workspace set (a small, bounded * per-workspace list), so today the cursor list is a single full page * (`nextCursor` always `null`). The canonical cursor envelope keeps the v2 list * surface uniform; real pagination can be added later behind the opaque cursor. + * Search, folder filter, and sort all run in that query, not over its result. */ export const v2ListKnowledgeBasesContract = defineRouteContract({ method: 'GET', path: '/api/v2/knowledge', - query: v1ListKnowledgeBasesQuerySchema, + query: v2ListKnowledgeBasesQuerySchema, response: { mode: 'json', schema: v2CursorListResponse(v2KnowledgeBaseSchema), diff --git a/apps/sim/lib/api/contracts/v2/mcp-servers.ts b/apps/sim/lib/api/contracts/v2/mcp-servers.ts index 54324148896..96b40e38b13 100644 --- a/apps/sim/lib/api/contracts/v2/mcp-servers.ts +++ b/apps/sim/lib/api/contracts/v2/mcp-servers.ts @@ -2,7 +2,12 @@ import { z } from 'zod' import { mcpAuthTypeSchema, mcpServerSchema, mcpTransportSchema } from '@/lib/api/contracts/mcp' import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' import { createEnvVarPattern } from '@/executor/utils/reference-validation' /** @@ -115,6 +120,17 @@ export const v2McpServerWorkspaceQuerySchema = z.object({ }) export type V2McpServerWorkspaceQuery = z.output +export const v2McpServerSortFields = ['name', 'createdAt', 'updatedAt'] as const + +export type V2McpServerSortBy = (typeof v2McpServerSortFields)[number] + +export const v2ListMcpServersQuerySchema = v2McpServerWorkspaceQuerySchema.extend({ + search: v2SearchSchema, + ...v2SortFields(v2McpServerSortFields, { sortBy: 'createdAt', sortOrder: 'desc' }), +}) + +export type V2ListMcpServersQuery = z.output + export const v2CreateMcpServerBodySchema = z .object({ workspaceId: workspaceIdSchema, @@ -166,7 +182,7 @@ export type V2UpdateMcpServerBody = z.input export const v2ListMcpServersContract = defineRouteContract({ method: 'GET', path: '/api/v2/mcp-servers', - query: v2McpServerWorkspaceQuerySchema, + query: v2ListMcpServersQuerySchema, response: { mode: 'json', schema: v2CursorListResponse(v2McpServerSchema), diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index d0579054727..02a10f692d9 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -17,6 +17,43 @@ import { z } from 'zod' * Rate-limit state is carried in `X-RateLimit-*` response headers (not the * body). Usage limits are available from the dedicated usage endpoint rather * than being inlined into every response. + * + * ## Search, filtering, and sorting + * + * One convention, applied by every v2 list. It is deliberately the narrow + * scalar-param form the app's own list endpoints already speak — not a third + * dialect alongside the Logs filter set and the Tables predicate grammar. + * A list that needs a real expression tree (Tables) keeps its own `POST /query`. + * + * - **`search`** ({@link v2SearchSchema}) — a case-insensitive substring match + * against the resource's *single* natural name field, and nothing else: + * `name` for files/folders/workflows/tables/knowledge bases/MCP servers/ + * skills, `title` for custom tools, `displayName` for credentials. It never + * matches ids, descriptions, or content. `%` and `_` in the term are matched + * literally, not as wildcards. Empty is rejected rather than silently + * ignored — omit the param instead. + * - **`sortBy` + `sortOrder`** ({@link v2SortFields}) — `sortBy` is a + * per-resource enum, never a free string, because the value selects a column + * in the query. `sortOrder` is `asc`/`desc`. Both always have a default, so + * an omitted sort is a defined order rather than whatever the planner + * returns. `position` names a resource's stored manual arrangement (the + * `sortOrder` *column* on workflows and folders) — it is spelled differently + * from the `sortOrder` *param* on purpose. + * - **Filters** — resource-specific and enumerated, reusing the names already + * on the surface (`scope`, `folderId`, `deployedOnly`, `type`, `providerId`, + * `resourceType`). No generic filter expression. + * + * Every one of these is pushed into SQL. No v2 list fetches a full result set + * to filter or sort it in memory. + * + * ## Sort and the opaque cursor + * + * On the lists that paginate ({@link v2CursorListResponse} with a non-null + * `nextCursor` — files and workflows), the cursor is a keyset over the *active* + * sort, so its keys change when the sort does. The sort is therefore encoded + * into the cursor and re-checked on the way back in: replaying a cursor under a + * different `sortBy`/`sortOrder` is a 400, not a silently duplicated or skipped + * page. Change the sort by restarting pagination without a cursor. */ /** Canonical v2 error envelope. */ @@ -37,3 +74,35 @@ export const v2CursorListResponse = (itemSchema: T) => data: z.array(itemSchema), nextCursor: z.string().nullable(), }) + +/** + * The v2 `search` term: a case-insensitive substring match on the resource's + * natural name field. Bounded at 200 characters — a longer term cannot match + * any of the name columns it is aimed at, and every one of these matches is an + * unindexed scan. + */ +export const v2SearchSchema = z + .string() + .trim() + .min(1, 'search cannot be empty') + .max(200, 'search is too long') + .optional() + +export const v2SortOrderSchema = z.enum(['asc', 'desc']) + +export type V2SortOrder = z.output + +/** + * The `sortBy` + `sortOrder` pair for one resource. `fields` is the closed set + * of sortable fields — the value reaches the query as a column, so it can never + * be a free string — and both params always resolve to the given defaults. + */ +export function v2SortFields( + fields: F, + defaults: { sortBy: F[number]; sortOrder: V2SortOrder } +) { + return { + sortBy: z.enum(fields).default(defaults.sortBy), + sortOrder: v2SortOrderSchema.default(defaults.sortOrder), + } +} diff --git a/apps/sim/lib/api/contracts/v2/skills.ts b/apps/sim/lib/api/contracts/v2/skills.ts index 3bc7174ea81..003151aef9f 100644 --- a/apps/sim/lib/api/contracts/v2/skills.ts +++ b/apps/sim/lib/api/contracts/v2/skills.ts @@ -6,7 +6,12 @@ import { skillNameSchema, } from '@/lib/api/contracts/skills' import { defineRouteContract } from '@/lib/api/contracts/types' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' /** * v2 skills contracts. @@ -64,6 +69,17 @@ export const v2SkillWorkspaceQuerySchema = z.object({ }) export type V2SkillWorkspaceQuery = z.output +export const v2SkillSortFields = ['name', 'createdAt', 'updatedAt'] as const + +export type V2SkillSortBy = (typeof v2SkillSortFields)[number] + +export const v2ListSkillsQuerySchema = v2SkillWorkspaceQuerySchema.extend({ + search: v2SearchSchema, + ...v2SortFields(v2SkillSortFields, { sortBy: 'createdAt', sortOrder: 'desc' }), +}) + +export type V2ListSkillsQuery = z.output + export const v2CreateSkillBodySchema = z .object({ workspaceId: workspaceIdSchema, @@ -105,7 +121,7 @@ export type V2UpdateSkillBody = z.input export const v2ListSkillsContract = defineRouteContract({ method: 'GET', path: '/api/v2/skills', - query: v2SkillWorkspaceQuerySchema, + query: v2ListSkillsQuerySchema, response: { mode: 'json', schema: v2CursorListResponse(v2SkillSummarySchema), diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 4c6f886ffe7..fc9d4046979 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -20,7 +20,12 @@ import { v1CreateTableRowsBodySchema, v1ListTablesQuerySchema, } from '@/lib/api/contracts/v1/tables' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' import { TABLE_LIMITS } from '@/lib/table/constants' /** @@ -141,17 +146,37 @@ export const v2UpsertRowDataSchema = z.object({ }) export type V2UpsertRowData = z.output +export const v2TableSortFields = ['name', 'createdAt', 'updatedAt'] as const + +export type V2TableSortBy = (typeof v2TableSortFields)[number] + +/** + * Table list query: the workspace scope every table route shares, plus the v2 + * search/sort convention and a folder filter. Kept separate from + * `v1ListTablesQuerySchema` — the single-table read/delete routes reuse that + * schema and have no list params. + */ +export const v2ListTablesQuerySchema = v1ListTablesQuerySchema.extend({ + /** Restrict to one table folder. */ + folderId: z.string().min(1, 'folderId cannot be empty').optional(), + search: v2SearchSchema, + ...v2SortFields(v2TableSortFields, { sortBy: 'createdAt', sortOrder: 'asc' }), +}) + +export type V2ListTablesQuery = z.output + /** * Table list. `listTables` returns every table in the workspace (a small, * bounded per-workspace set), so today the cursor list is a single full page * (`nextCursor` is always `null`). Using the canonical cursor envelope keeps the * whole v2 list surface uniform, and real pagination can be added later behind - * the opaque cursor without an interface change. + * the opaque cursor without an interface change. Search, folder filter, and + * sort all run in that query, not over its result. */ export const v2ListTablesContract = defineRouteContract({ method: 'GET', path: '/api/v2/tables', - query: v1ListTablesQuerySchema, + query: v2ListTablesQuerySchema, response: { mode: 'json', schema: v2CursorListResponse(v2ApiTableSchema), diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index b721f347b7c..76e944480b9 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -8,7 +8,12 @@ import { v1RollbackWorkflowDataSchema, v1WorkflowExportPayloadSchema, } from '@/lib/api/contracts/v1/workflows' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' import { cancelWorkflowExecutionReasonSchema, workflowExecutionParamsSchema, @@ -18,13 +23,41 @@ import { } from '@/lib/api/contracts/workflows' /** - * v2 workflows contracts. Request shapes are reused verbatim from v1 (the list - * query and `[id]` param are unchanged); only the response envelope is upgraded - * to the canonical v2 shapes with concrete item/detail schemas. The - * deploy/rollback/undeploy data payloads reuse the already-concrete v1 schemas, - * re-wrapped in `v2DataResponse` (the v1 `limits` body field is dropped — v2 - * carries rate-limit state in headers and usage on a dedicated endpoint). + * v2 workflows contracts. Request shapes are reused from v1 (the `[id]` param + * is unchanged, and the list query extends v1's with the v2 search/sort + * convention); only the response envelope is upgraded to the canonical v2 + * shapes with concrete item/detail schemas. The deploy/rollback/undeploy data + * payloads reuse the already-concrete v1 schemas, re-wrapped in + * `v2DataResponse` (the v1 `limits` body field is dropped — v2 carries + * rate-limit state in headers and usage on a dedicated endpoint). + */ + +/** + * Sortable workflow fields. `position` is the workspace's manual arrangement + * (the `sort_order` column the sidebar writes), kept as the default so a bare + * list still returns workflows in the order the workspace put them in. + */ +export const v2WorkflowSortFields = [ + 'position', + 'name', + 'createdAt', + 'updatedAt', + 'runCount', +] as const + +export type V2WorkflowSortBy = (typeof v2WorkflowSortFields)[number] + +/** + * List query: v1's workspace/folder/deployment filters plus the v2 search and + * sort convention. The keyset behind the cursor follows `sortBy`, so the cursor + * carries the sort it was minted under and is rejected once that changes. */ +export const v2ListWorkflowsQuerySchema = v1ListWorkflowsQuerySchema.extend({ + search: v2SearchSchema, + ...v2SortFields(v2WorkflowSortFields, { sortBy: 'position', sortOrder: 'asc' }), +}) + +export type V2ListWorkflowsQuery = z.output export const v2WorkflowListItemSchema = z.object({ id: z.string(), @@ -73,7 +106,7 @@ const v2UndeployWorkflowDataSchema = v1DeployWorkflowDataSchema.omit({ version: export const v2ListWorkflowsContract = defineRouteContract({ method: 'GET', path: '/api/v2/workflows', - query: v1ListWorkflowsQuerySchema, + query: v2ListWorkflowsQuerySchema, response: { mode: 'json', schema: v2CursorListResponse(v2WorkflowListItemSchema), diff --git a/apps/sim/lib/api/list-convention.test.ts b/apps/sim/lib/api/list-convention.test.ts new file mode 100644 index 00000000000..fcb2594dc26 --- /dev/null +++ b/apps/sim/lib/api/list-convention.test.ts @@ -0,0 +1,225 @@ +/** + * @vitest-environment node + * + * One test per v2 list-backing query, asserting the same two things everywhere: + * `search` becomes a bound case-insensitive substring predicate on that + * resource's natural name column, and `sortBy` selects the ordering columns. + * + * This is the surface-wide guard the convention needs. `list-query.test.ts` + * proves the generated SQL is parameterized and wildcard-escaped; what can still + * go wrong per resource is aiming the search at the wrong column, or a sort that + * never reaches `ORDER BY` — both visible only in the query each lib builds. + * + * Files (`queryWorkspaceFiles`) and workflows have their own suites, since they + * additionally paginate. + */ +import { + dbChainMockFns, + flattenMockConditions, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/billing/core/subscription', () => ({ getHighestPrioritySubscription: vi.fn() })) +vi.mock('@/lib/billing/core/usage', () => ({ ensureUserStatsExists: vi.fn() })) +vi.mock('@/lib/billing/storage', () => ({ + applyStorageUsageDeltasInTx: vi.fn(), + decrementStorageUsageForBillingContextInTx: vi.fn(), + incrementStorageUsageForBillingContextInTx: vi.fn(), + maybeNotifyStorageLimitForBillingContext: vi.fn(), + resolveStorageBillingContext: vi.fn(), +})) +vi.mock('@/lib/table/billing', () => ({ + assertRowCapacity: vi.fn(), + notifyTableRowUsage: vi.fn(), +})) +vi.mock('@/lib/table/jobs/service', () => ({ + EMPTY_JOB_FIELDS: {}, + latestJobForTable: vi.fn(async () => null), + latestJobsForTables: vi.fn(async () => new Map()), +})) +vi.mock('@/lib/table/events', () => ({ appendTableEvent: vi.fn() })) +vi.mock('@/lib/realtime/notify', () => ({ + mergeEditIntoLiveFileDoc: vi.fn(), + notifyWorkspaceFilesChanged: vi.fn(), + notifyWorkspaceTablesChanged: vi.fn(), +})) +vi.mock('@/lib/skills/access', () => ({ getEditableSkillIds: vi.fn() })) +vi.mock('@/lib/workflows/skills/builtin-skills', () => ({ + BUILTIN_SKILLS: [], + getBuiltinSkillById: vi.fn(), + isBuiltinSkillId: vi.fn(() => false), +})) + +import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' +import { listFoldersForWorkspace } from '@/lib/folders/queries' +import { getKnowledgeBases } from '@/lib/knowledge/service' +import { listWorkspaceMcpServers } from '@/lib/mcp/queries' +import { listTables } from '@/lib/table/service' +import { listWorkspaceCustomTools } from '@/lib/workflows/custom-tools/operations' +import { listSkills } from '@/lib/workflows/skills/operations' + +const WS = 'workspace-1' + +const lastConditions = () => + flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]).filter(Boolean) + +const lastOrderBy = () => dbChainMockFns.orderBy.mock.calls.at(-1) ?? [] + +const searchNode = () => lastConditions().find((c) => c.type === 'ilike') + +interface ListCase { + name: string + /** Column the resource's `search` must match on. */ + column: unknown + /** Rows table to queue against, so the chain resolves. */ + table: unknown + run: (options: { + search?: string + sortBy?: string + sortOrder?: 'asc' | 'desc' + }) => Promise + /** A non-default sort, and the columns it must order by. */ + sort: { sortBy: string; sortOrder: 'asc' | 'desc'; columns: unknown[] } +} + +const CASES: ListCase[] = [ + { + name: 'folders', + column: schemaMock.folder.name, + table: schemaMock.folder, + run: ({ search, sortBy, sortOrder }) => + listFoldersForWorkspace(WS, 'active', 'workflow', { + search, + sortBy: sortBy as never, + sortOrder, + }), + sort: { + sortBy: 'name', + sortOrder: 'desc', + columns: [schemaMock.folder.name, schemaMock.folder.createdAt], + }, + }, + { + name: 'tables', + column: schemaMock.userTableDefinitions.name, + table: schemaMock.userTableDefinitions, + run: ({ search, sortBy, sortOrder }) => + listTables(WS, { search, sortBy: sortBy as never, sortOrder }), + sort: { + sortBy: 'updatedAt', + sortOrder: 'desc', + columns: [ + schemaMock.userTableDefinitions.updatedAt, + schemaMock.userTableDefinitions.createdAt, + ], + }, + }, + { + name: 'knowledge bases', + column: schemaMock.knowledgeBase.name, + table: schemaMock.knowledgeBase, + run: ({ search, sortBy, sortOrder }) => + getKnowledgeBases('user-1', WS, 'active', { search, sortBy: sortBy as never, sortOrder }), + sort: { + sortBy: 'name', + sortOrder: 'asc', + columns: [schemaMock.knowledgeBase.name, schemaMock.knowledgeBase.createdAt], + }, + }, + { + name: 'credentials', + column: schemaMock.credential.displayName, + table: schemaMock.credential, + run: ({ search, sortBy, sortOrder }) => + listVisibleWorkspaceCredentials({ + workspaceId: WS, + userId: 'user-1', + workspaceAccess: { canAdmin: false }, + search, + sortBy: sortBy as never, + sortOrder, + }), + sort: { + sortBy: 'displayName', + sortOrder: 'asc', + columns: [schemaMock.credential.displayName, schemaMock.credential.id], + }, + }, + { + name: 'MCP servers', + column: schemaMock.mcpServers.name, + table: schemaMock.mcpServers, + run: ({ search, sortBy, sortOrder }) => + listWorkspaceMcpServers({ workspaceId: WS, search, sortBy: sortBy as never, sortOrder }), + sort: { + sortBy: 'name', + sortOrder: 'asc', + columns: [schemaMock.mcpServers.name, schemaMock.mcpServers.id], + }, + }, + { + name: 'custom tools', + column: schemaMock.customTools.title, + table: schemaMock.customTools, + run: ({ search, sortBy, sortOrder }) => + listWorkspaceCustomTools({ workspaceId: WS, search, sortBy: sortBy as never, sortOrder }), + sort: { + sortBy: 'title', + sortOrder: 'asc', + columns: [schemaMock.customTools.title, schemaMock.customTools.id], + }, + }, + { + name: 'skills', + column: schemaMock.skill.name, + table: schemaMock.skill, + run: ({ search, sortBy, sortOrder }) => + listSkills({ + workspaceId: WS, + search, + sort: sortBy ? { sortBy: sortBy as never, sortOrder: sortOrder ?? 'desc' } : undefined, + }), + sort: { + sortBy: 'name', + sortOrder: 'asc', + columns: [schemaMock.skill.name, schemaMock.skill.id], + }, + }, +] + +describe.each(CASES)('$name list query', (listCase) => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + queueTableRows(listCase.table, []) + }) + + it('narrows the query with a case-insensitive substring match on its name column', async () => { + await listCase.run({ search: 'quarterly' }) + + expect(searchNode()).toMatchObject({ column: listCase.column, pattern: '%quarterly%' }) + }) + + it('escapes LIKE wildcards so a caller cannot widen its own match', async () => { + await listCase.run({ search: '50%_off' }) + + expect(searchNode()).toMatchObject({ pattern: '%50\\%\\_off%' }) + }) + + it('adds no search condition when the caller did not search', async () => { + await listCase.run({}) + + expect(searchNode()).toBeUndefined() + }) + + it('orders by the requested field and direction', async () => { + const { sortBy, sortOrder, columns } = listCase.sort + + await listCase.run({ sortBy, sortOrder }) + + expect(lastOrderBy()).toEqual(columns.map((column) => ({ type: sortOrder, column }))) + }) +}) diff --git a/apps/sim/lib/api/list-query.test.ts b/apps/sim/lib/api/list-query.test.ts new file mode 100644 index 00000000000..4da992aa2f4 --- /dev/null +++ b/apps/sim/lib/api/list-query.test.ts @@ -0,0 +1,193 @@ +/** + * @vitest-environment node + * + * The v2 list convention's SQL half. These run against REAL drizzle (the global + * `drizzle-orm` mock is lifted for this file) and render the generated SQL, so + * the assertions are about the query that would actually be sent — the point + * being that a caller's `search` term only ever arrives as a bound parameter. + */ +import { describe, expect, it, vi } from 'vitest' + +vi.unmock('drizzle-orm') + +import { integer, PgDialect, pgTable, text, timestamp } from 'drizzle-orm/pg-core' +import { + encodeKeyset, + escapeLikePattern, + keysetAfter, + keysetColumns, + listOrderBy, + numberKey, + searchFilter, + textKey, + timestampKey, +} from '@/lib/api/list-query' + +const thing = pgTable('thing', { + id: text('id').primaryKey(), + name: text('name').notNull(), + size: integer('size').notNull(), + createdAt: timestamp('created_at').notNull(), +}) + +const dialect = new PgDialect() + +function render(fragment: Parameters[0]) { + return dialect.sqlToQuery(fragment) +} + +describe('escapeLikePattern', () => { + it('neutralizes the LIKE wildcards so a caller cannot widen its own match', () => { + expect(escapeLikePattern('100%')).toBe('100\\%') + expect(escapeLikePattern('a_b')).toBe('a\\_b') + expect(escapeLikePattern('back\\slash')).toBe('back\\\\slash') + }) + + it('leaves an ordinary term untouched', () => { + expect(escapeLikePattern('quarterly report')).toBe('quarterly report') + }) +}) + +describe('searchFilter', () => { + it('binds the caller term as a parameter instead of inlining it into the SQL', () => { + const { sql, params } = render(searchFilter(thing.name, "o'brien; drop table thing --")!) + + expect(sql).toBe('"thing"."name" ilike $1') + expect(params).toEqual(["%o'brien; drop table thing --%"]) + expect(sql).not.toContain('drop table') + }) + + it('escapes wildcards inside the bound pattern', () => { + const { params } = render(searchFilter(thing.name, '50%_off')!) + + expect(params).toEqual(['%50\\%\\_off%']) + }) + + it('is case-insensitive (ILIKE, not LIKE)', () => { + const { sql } = render(searchFilter(thing.name, 'Report')!) + + expect(sql).toContain('ilike') + }) + + it('drops out of the WHERE clause entirely when no term was given', () => { + expect(searchFilter(thing.name, undefined)).toBeUndefined() + }) +}) + +describe('listOrderBy', () => { + it('applies the direction to every key so the ordering is total', () => { + const [first, second] = listOrderBy([thing.name, thing.id], 'desc') + + expect(render(first).sql).toBe('"thing"."name" desc') + expect(render(second).sql).toBe('"thing"."id" desc') + }) +}) + +interface Row { + id: string + name: string + createdAt: Date +} + +const nameKey = textKey(thing.name, (r) => r.name) +const idKey = textKey(thing.id, (r) => r.id) +const createdKey = timestampKey(thing.createdAt, (r) => r.createdAt) + +describe('timestampKey', () => { + /** + * The regression this exists for: Postgres keeps microseconds, a cursor value + * round-trips through a millisecond-only JS Date, and comparing the raw column + * against the truncated value re-admits the page's own last row. + */ + it('orders on the millisecond-truncated column so the cursor can express the ordering', () => { + expect(render(createdKey.expr as never).sql).toBe( + `date_trunc('milliseconds', "thing"."created_at")` + ) + }) + + it('truncates the bound cursor value to match, binding it through the column encoder', () => { + const { sql: text, params } = render(createdKey.bind('2024-01-01T00:00:00.123Z')!) + + expect(text).toBe(`date_trunc('milliseconds', $1)`) + expect(params).toEqual(['2024-01-01T00:00:00.123Z']) + }) + + it('rejects a cursor value that is not a parseable timestamp', () => { + expect(createdKey.bind('not-a-date')).toBeNull() + expect(createdKey.bind(1700000000000)).toBeNull() + }) +}) + +describe('cursor key value validation', () => { + it('rejects a non-string for a text key', () => { + expect(nameKey.bind(42)).toBeNull() + expect(nameKey.bind('ok')).not.toBeNull() + }) + + it('rejects a non-finite or non-numeric value for a numeric key', () => { + const sizeKey = numberKey(thing.size, () => 0) + + expect(sizeKey.bind('12')).toBeNull() + expect(sizeKey.bind(Number.NaN)).toBeNull() + expect(sizeKey.bind(Number.POSITIVE_INFINITY)).toBeNull() + expect(sizeKey.bind(12)).not.toBeNull() + }) +}) + +describe('encodeKeyset / keysetColumns', () => { + it('reads the cursor values and the ordering expressions in key order', () => { + const row: Row = { id: 'file-7', name: 'data.csv', createdAt: new Date('2024-03-04T05:06:07Z') } + + expect(encodeKeyset([nameKey, idKey], row)).toEqual(['data.csv', 'file-7']) + expect(keysetColumns([nameKey, idKey])).toEqual([thing.name, thing.id]) + }) +}) + +describe('keysetAfter', () => { + it('expands lexicographically so a tie on a leading key falls through', () => { + const { sql: text, params } = render( + keysetAfter([nameKey, idKey], ['data.csv', 'file-7'], 'asc')! + ) + + expect(text).toBe('("thing"."name" > $1 or ("thing"."name" = $2 and "thing"."id" > $3))') + expect(params).toEqual(['data.csv', 'data.csv', 'file-7']) + }) + + it('flips the comparison for a descending sort', () => { + const { sql: text } = render(keysetAfter([nameKey, idKey], ['b', 'x'], 'desc')!) + + expect(text).toContain('"thing"."name" < $1') + expect(text).not.toContain('>') + }) + + it('binds every keyset value as a parameter', () => { + const { sql: text, params } = render(keysetAfter([idKey], ["'; delete from thing --"], 'asc')!) + + expect(text).toBe('"thing"."id" > $1') + expect(params).toEqual(["'; delete from thing --"]) + }) + + it('compares the truncated timestamp on both sides', () => { + const { sql: text, params } = render( + keysetAfter([createdKey, idKey], ['2024-01-01T00:00:00.123Z', 'file-7'], 'asc')! + ) + + expect(text).toBe( + `(date_trunc('milliseconds', "thing"."created_at") > date_trunc('milliseconds', $1) or ` + + `(date_trunc('milliseconds', "thing"."created_at") = date_trunc('milliseconds', $2) and ` + + `"thing"."id" > $3))` + ) + expect(params).toEqual(['2024-01-01T00:00:00.123Z', '2024-01-01T00:00:00.123Z', 'file-7']) + }) + + /** A caller controls the cursor's contents, so a bad value is a 400, not a 500 from SQL. */ + it('refuses a cursor carrying a value its key cannot hold', () => { + expect(keysetAfter([createdKey, idKey], ['not-a-date', 'file-7'], 'asc')).toBeNull() + expect(keysetAfter([nameKey, idKey], [7, 'file-7'], 'asc')).toBeNull() + }) + + it('refuses a cursor with the wrong number of keys for the sort', () => { + expect(keysetAfter([nameKey, idKey], ['only-one'], 'asc')).toBeNull() + expect(keysetAfter([nameKey, idKey], ['a', 'b', 'c'], 'asc')).toBeNull() + }) +}) diff --git a/apps/sim/lib/api/list-query.ts b/apps/sim/lib/api/list-query.ts new file mode 100644 index 00000000000..d5f74aa9660 --- /dev/null +++ b/apps/sim/lib/api/list-query.ts @@ -0,0 +1,176 @@ +import { + and, + asc, + type Column, + desc, + eq, + gt, + ilike, + lt, + or, + type SQL, + type SQLWrapper, + sql, +} from 'drizzle-orm' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' + +/** + * Runtime half of the v2 list convention declared in + * `lib/api/contracts/v2/shared.ts`: turns a validated `search` term and a + * validated `sortBy`/`sortOrder` pair into SQL. + * + * Nothing here accepts a caller string as SQL. `search` becomes a bound ILIKE + * parameter, a sort is only ever expressed as one of the keys the resource + * itself listed (the contract enum is what makes that lookup total), and a + * cursor's values are type-checked against their key before they are bound. + */ + +/** + * Escapes LIKE/ILIKE wildcards so `%`, `_`, and `\` in a caller's term match + * themselves. Postgres treats `\` as the default LIKE escape character, so no + * explicit `ESCAPE` clause is needed. + * + * `lib/table/sql.ts` carries its own copy for the JSONB predicate engine; the + * two are worth folding together, but that module is table-specific and pulls + * the whole column-type registry with it. + */ +export function escapeLikePattern(value: string): string { + return value.replace(/[\\%_]/g, '\\$&') +} + +/** + * Case-insensitive substring predicate for a v2 `search` term, or `undefined` + * when the caller did not search (which drops out of an `and(...)`). + */ +export function searchFilter(column: Column, term: string | undefined): SQL | undefined { + if (term === undefined) return undefined + return ilike(column, `%${escapeLikePattern(term)}%`) +} + +/** A cursor key value, as it survives the base64-JSON round trip. */ +export type CursorKey = string | number + +/** Caller-facing message for a cursor that cannot be resumed under the requested sort. */ +export const INVALID_CURSOR_MESSAGE = + 'cursor does not match the requested sortBy/sortOrder. Restart pagination without a cursor after changing the sort.' + +/** + * One column of a keyset ordering, with the codec that moves its value through + * the opaque cursor. + * + * `bind` returning `null` is how a malformed cursor becomes a 400. The values + * inside a cursor are caller-controlled, so "the sort stamp and key count + * match" is not enough — a non-numeric `size` or an unparseable timestamp has + * to be rejected at the boundary instead of reaching the query as `NaN` or an + * `Invalid Date`, which surfaces as a 500. + */ +export interface KeysetKey { + /** The expression this key both orders and compares on. */ + expr: SQLWrapper + /** This key's cursor value for `row`. */ + encode: (row: Row) => CursorKey + /** The cursor value as bindable SQL, or `null` when this key cannot hold it. */ + bind: (value: CursorKey) => SQL | null +} + +/** A text key — names, titles, ids. */ +export function textKey(column: Column, read: (row: Row) => string): KeysetKey { + return { + expr: column, + encode: read, + bind: (value) => (typeof value === 'string' ? sql`${value}` : null), + } +} + +/** A numeric key — sizes, counts, manual positions. */ +export function numberKey(column: Column, read: (row: Row) => number): KeysetKey { + return { + expr: column, + encode: read, + bind: (value) => (typeof value === 'number' && Number.isFinite(value) ? sql`${value}` : null), + } +} + +/** + * A timestamp key, ordered and compared at millisecond precision. + * + * Postgres keeps microseconds and `defaultNow()` populates them, but a cursor + * value round-trips through a JS `Date`, which cannot represent them. Ordering + * on the raw column while comparing against a truncated cursor value re-admits + * the page's own last row — `stored > truncated` is true for it — which + * duplicates that row and stalls pagination outright at a page size of one. + * Truncating both sides makes the SQL ordering exactly the ordering a cursor + * can express, so the `id` tiebreaker is what actually separates rows inside a + * millisecond. + * + * `date_trunc` rules out an index-ordered scan, but none of the timestamp + * columns sorted here are indexed, so it costs nothing today. Adding an index + * to serve one of these sorts means indexing this same expression. + */ +export function timestampKey(column: Column, read: (row: Row) => Date): KeysetKey { + return { + expr: sql`date_trunc('milliseconds', ${column})`, + encode: (row) => read(row).toISOString(), + bind: (value) => { + if (typeof value !== 'string') return null + const date = new Date(value) + if (Number.isNaN(date.getTime())) return null + // Bound through the column so drizzle's own timestamp encoder serializes it. + return sql`date_trunc('milliseconds', ${sql.param(date, column)})` + }, + } +} + +export function sortDirection(order: V2SortOrder): typeof asc { + return order === 'asc' ? asc : desc +} + +/** + * `ORDER BY` for an ordered key list, every key taking the requested direction. + * On a paginated list these are the keyset's keys; on a single-page list they + * are just the sort plus its tiebreaker. + */ +export function listOrderBy(keys: readonly SQLWrapper[], order: V2SortOrder): SQL[] { + const direction = sortDirection(order) + return keys.map((key) => direction(key)) +} + +/** The `expr` of each keyset key, for `ORDER BY`. */ +export function keysetColumns(keys: readonly KeysetKey[]): SQLWrapper[] { + return keys.map((key) => key.expr) +} + +/** The cursor values for `row`, in key order. */ +export function encodeKeyset(keys: readonly KeysetKey[], row: Row): CursorKey[] { + return keys.map((key) => key.encode(row)) +} + +/** + * The `WHERE` half of the keyset: strictly after `values` in the requested + * direction, expanded lexicographically so ties on a leading key fall through + * to the next one. + * + * Returns `null` when the cursor does not fit this sort — wrong number of keys, + * or a value the key cannot hold. Callers render that as a 400 rather than + * paging from a nonsense position. + */ +export function keysetAfter( + keys: readonly KeysetKey[], + values: CursorKey[], + order: V2SortOrder +): SQL | null { + if (values.length !== keys.length) return null + + const bound: SQL[] = [] + for (const [i, key] of keys.entries()) { + const value = key.bind(values[i]) + if (value === null) return null + bound.push(value) + } + + const beyond = order === 'asc' ? gt : lt + const clauses = keys.map((key, i) => + and(...keys.slice(0, i).map((prior, j) => eq(prior.expr, bound[j])), beyond(key.expr, bound[i])) + ) + return or(...clauses) ?? null +} diff --git a/apps/sim/lib/credentials/queries.ts b/apps/sim/lib/credentials/queries.ts index 397e96b9f59..ef0fccd4267 100644 --- a/apps/sim/lib/credentials/queries.ts +++ b/apps/sim/lib/credentials/queries.ts @@ -1,7 +1,10 @@ import { db } from '@sim/db' import { credential, credentialMember } from '@sim/db/schema' -import { and, eq, inArray, isNotNull, or } from 'drizzle-orm' +import { and, type Column, eq, inArray, isNotNull, or } from 'drizzle-orm' import type { WorkspaceCredentialType } from '@/lib/api/contracts/credentials' +import type { V2CredentialSortBy } from '@/lib/api/contracts/v2/credentials' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' import { isSharedCredentialType, SHARED_CREDENTIAL_TYPES } from '@/lib/credentials/access' import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -36,14 +39,38 @@ export interface VisibleWorkspaceCredential { * admins — every shared-type credential, plus the caller's own personal env * credentials. Encrypted secret material is never selected. */ +/** + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `id` so credentials sharing a display name + * or a timestamp still come back in a stable order. + */ +const CREDENTIAL_SORTS = { + displayName: [credential.displayName, credential.id], + createdAt: [credential.createdAt, credential.id], + updatedAt: [credential.updatedAt, credential.id], +} satisfies Record + export async function listVisibleWorkspaceCredentials(params: { workspaceId: string userId: string workspaceAccess: Pick type?: WorkspaceCredentialType providerId?: string + /** Case-insensitive substring match on the credential display name. */ + search?: string + sortBy?: V2CredentialSortBy + sortOrder?: V2SortOrder }): Promise { - const { workspaceId, userId, workspaceAccess, type, providerId } = params + const { + workspaceId, + userId, + workspaceAccess, + type, + providerId, + search, + sortBy = 'createdAt', + sortOrder = 'desc', + } = params const whereClauses = [eq(credential.workspaceId, workspaceId)] if (type) whereClauses.push(eq(credential.type, type)) @@ -84,7 +111,8 @@ export async function listVisibleWorkspaceCredentials(params: { eq(credentialMember.status, 'active') ) ) - .where(and(...whereClauses, accessClause)) + .where(and(...whereClauses, accessClause, searchFilter(credential.displayName, search))) + .orderBy(...listOrderBy(CREDENTIAL_SORTS[sortBy], sortOrder)) return rows.map(({ memberRole, encryptedServiceAccountKey, ...rest }) => ({ ...rest, diff --git a/apps/sim/lib/folders/queries.ts b/apps/sim/lib/folders/queries.ts index 092387cea90..98f616e1a21 100644 --- a/apps/sim/lib/folders/queries.ts +++ b/apps/sim/lib/folders/queries.ts @@ -1,7 +1,10 @@ import { db } from '@sim/db' import { folder } from '@sim/db/schema' -import { and, asc, eq, isNotNull, isNull } from 'drizzle-orm' +import { and, type Column, eq, isNotNull, isNull } from 'drizzle-orm' import type { FolderApi, FolderResourceType } from '@/lib/api/contracts/folders' +import type { V2FolderSortBy } from '@/lib/api/contracts/v2/folders' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' import type { FolderQueryScope } from '@/hooks/queries/utils/folder-keys' /** @@ -135,21 +138,52 @@ export async function resolveRestoredFolderId( return (await findActiveFolder(folderId, workspaceId, resourceType)) ? folderId : null } -/** Shared by `GET /api/folders` and the sidebar prefetch so the query never drifts between them. */ +/** + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `createdAt` so folders sharing a name or a + * `sortOrder` still come back in a stable order. + */ +const FOLDER_SORTS = { + position: [folder.sortOrder, folder.createdAt], + name: [folder.name, folder.createdAt], + createdAt: [folder.createdAt], + updatedAt: [folder.updatedAt, folder.createdAt], +} satisfies Record + +interface ListFoldersOptions { + /** Case-insensitive substring match on the folder name. */ + search?: string + sortBy?: V2FolderSortBy + sortOrder?: V2SortOrder +} + +/** + * Shared by `GET /api/folders`, the public v2 list, and the sidebar prefetch so + * the query never drifts between them. Search and sort are applied in the + * query; the in-app callers omit them and keep the default `position` ordering. + */ export async function listFoldersForWorkspace( workspaceId: string, scope: FolderQueryScope, - resourceType: FolderResourceType + resourceType: FolderResourceType, + options?: ListFoldersOptions ): Promise { const scopeFilter = scope === 'archived' ? isNotNull(folder.deletedAt) : isNull(folder.deletedAt) + const sortBy = options?.sortBy ?? 'position' + const sortOrder = options?.sortOrder ?? 'asc' const rows = await db .select() .from(folder) .where( - and(eq(folder.workspaceId, workspaceId), eq(folder.resourceType, resourceType), scopeFilter) + and( + eq(folder.workspaceId, workspaceId), + eq(folder.resourceType, resourceType), + scopeFilter, + searchFilter(folder.name, options?.search) + ) ) - .orderBy(asc(folder.sortOrder), asc(folder.createdAt)) + .orderBy(...listOrderBy(FOLDER_SORTS[sortBy], sortOrder)) return rows.map(toFolderApi) } diff --git a/apps/sim/lib/knowledge/service.test.ts b/apps/sim/lib/knowledge/service.test.ts index ce59bc0087b..7399b83aebe 100644 --- a/apps/sim/lib/knowledge/service.test.ts +++ b/apps/sim/lib/knowledge/service.test.ts @@ -82,7 +82,7 @@ describe('updateKnowledgeBase — workspace transfer authorization', () => { await expect( updateKnowledgeBase('kb-1', { workspaceId: null }, 'req-1', { actorUserId: 'owner' }) - ).rejects.not.toBeInstanceOf(KnowledgeBasePermissionError) + ).resolves.not.toThrow() expect(permissionsMockFns.mockGetUserEntityPermissions).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index e4e8cc3707b..6131ae58fd0 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -10,7 +10,22 @@ import { import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, count, eq, exists, inArray, isNotNull, isNull, ne, or, sql } from 'drizzle-orm' +import { + and, + type Column, + count, + eq, + exists, + inArray, + isNotNull, + isNull, + ne, + or, + sql, +} from 'drizzle-orm' +import type { V2KnowledgeBaseSortBy } from '@/lib/api/contracts/v2/knowledge' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' import type { HighestPrioritySubscription } from '@/lib/billing/core/plan' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { ensureUserStatsExists } from '@/lib/billing/core/usage' @@ -108,13 +123,38 @@ type KnowledgeBaseStorageMove = } /** - * Get knowledge bases that a user can access + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `createdAt` so knowledge bases sharing a + * name still come back in a stable order. + */ +const KNOWLEDGE_BASE_SORTS = { + name: [knowledgeBase.name, knowledgeBase.createdAt], + createdAt: [knowledgeBase.createdAt], + updatedAt: [knowledgeBase.updatedAt, knowledgeBase.createdAt], +} satisfies Record + +interface GetKnowledgeBasesOptions { + /** Restrict to one knowledge-base folder. */ + folderId?: string + /** Case-insensitive substring match on the knowledge base name. */ + search?: string + sortBy?: V2KnowledgeBaseSortBy + sortOrder?: V2SortOrder +} + +/** + * Get knowledge bases that a user can access. + * + * Filter and sort are applied in the query, so a search costs one narrowed scan + * rather than materializing every knowledge base the caller can reach. */ export async function getKnowledgeBases( userId: string, workspaceId?: string | null, - scope: KnowledgeBaseScope = 'active' + scope: KnowledgeBaseScope = 'active', + options?: GetKnowledgeBasesOptions ): Promise { + const { folderId, search, sortBy = 'createdAt', sortOrder = 'asc' } = options ?? {} const scopeCondition = scope === 'all' ? undefined @@ -161,6 +201,8 @@ export async function getKnowledgeBases( .where( and( scopeCondition, + folderId ? eq(knowledgeBase.folderId, folderId) : undefined, + searchFilter(knowledgeBase.name, search), workspaceId ? // When filtering by workspace or( @@ -183,7 +225,7 @@ export async function getKnowledgeBases( ) ) .groupBy(knowledgeBase.id) - .orderBy(knowledgeBase.createdAt) + .orderBy(...listOrderBy(KNOWLEDGE_BASE_SORTS[sortBy], sortOrder)) const kbIds = knowledgeBasesWithCounts.map((kb) => kb.id) diff --git a/apps/sim/lib/mcp/queries.ts b/apps/sim/lib/mcp/queries.ts index 81a50f0b1d6..789a86a4454 100644 --- a/apps/sim/lib/mcp/queries.ts +++ b/apps/sim/lib/mcp/queries.ts @@ -1,6 +1,9 @@ import { db } from '@sim/db' import { mcpServers } from '@sim/db/schema' -import { and, desc, eq, isNull } from 'drizzle-orm' +import { and, type Column, eq, isNull } from 'drizzle-orm' +import type { V2McpServerSortBy } from '@/lib/api/contracts/v2/mcp-servers' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' /** * Workspace-scoped MCP server reads. The lifecycle functions in @@ -11,14 +14,36 @@ import { and, desc, eq, isNull } from 'drizzle-orm' export type McpServerRow = typeof mcpServers.$inferSelect /** Live (non-soft-deleted) MCP servers in a workspace, newest first. */ +/** + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `id` so servers sharing a name or a + * timestamp still come back in a stable order. + */ +const MCP_SERVER_SORTS = { + name: [mcpServers.name, mcpServers.id], + createdAt: [mcpServers.createdAt, mcpServers.id], + updatedAt: [mcpServers.updatedAt, mcpServers.id], +} satisfies Record + export async function listWorkspaceMcpServers(params: { workspaceId: string + /** Case-insensitive substring match on the server name. */ + search?: string + sortBy?: V2McpServerSortBy + sortOrder?: V2SortOrder }): Promise { + const { sortBy = 'createdAt', sortOrder = 'desc' } = params return db .select() .from(mcpServers) - .where(and(eq(mcpServers.workspaceId, params.workspaceId), isNull(mcpServers.deletedAt))) - .orderBy(desc(mcpServers.createdAt)) + .where( + and( + eq(mcpServers.workspaceId, params.workspaceId), + isNull(mcpServers.deletedAt), + searchFilter(mcpServers.name, params.search) + ) + ) + .orderBy(...listOrderBy(MCP_SERVER_SORTS[sortBy], sortOrder)) } /** A single live MCP server, or null when it does not exist in this workspace. */ diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 99f9cf82404..dd41b525317 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -13,7 +13,10 @@ import { tableJobs, userTableDefinitions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, count, eq, isNull, sql } from 'drizzle-orm' +import { and, type Column, count, eq, isNotNull, isNull, sql } from 'drizzle-orm' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import type { V2TableSortBy } from '@/lib/api/contracts/v2/tables' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' @@ -206,17 +209,47 @@ export async function getTableById( } } +/** + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `createdAt` so tables sharing a name still + * come back in a stable order. + */ +const TABLE_SORTS = { + name: [userTableDefinitions.name, userTableDefinitions.createdAt], + createdAt: [userTableDefinitions.createdAt], + updatedAt: [userTableDefinitions.updatedAt, userTableDefinitions.createdAt], +} satisfies Record + +interface ListTablesOptions { + scope?: TableScope + /** Restrict to one table folder. */ + folderId?: string + /** Case-insensitive substring match on the table name. */ + search?: string + sortBy?: V2TableSortBy + sortOrder?: V2SortOrder +} + /** * Lists all tables in a workspace. * + * Filter and sort are applied in the query — a name search must not become + * "read every table in the workspace, then discard most of them". + * * @param workspaceId - Workspace ID to list tables for * @returns Array of table definitions */ export async function listTables( workspaceId: string, - options?: { scope?: TableScope } + options?: ListTablesOptions ): Promise { - const { scope = 'active' } = options ?? {} + const { + scope = 'active', + folderId, + search, + sortBy = 'createdAt', + sortOrder = 'asc', + } = options ?? {} const tables = await db .select({ id: userTableDefinitions.id, @@ -236,19 +269,18 @@ export async function listTables( }) .from(userTableDefinitions) .where( - scope === 'all' - ? eq(userTableDefinitions.workspaceId, workspaceId) - : scope === 'archived' - ? and( - eq(userTableDefinitions.workspaceId, workspaceId), - sql`${userTableDefinitions.archivedAt} IS NOT NULL` - ) - : and( - eq(userTableDefinitions.workspaceId, workspaceId), - isNull(userTableDefinitions.archivedAt) - ) + and( + eq(userTableDefinitions.workspaceId, workspaceId), + scope === 'all' + ? undefined + : scope === 'archived' + ? isNotNull(userTableDefinitions.archivedAt) + : isNull(userTableDefinitions.archivedAt), + folderId ? eq(userTableDefinitions.folderId, folderId) : undefined, + searchFilter(userTableDefinitions.name, search) + ) ) - .orderBy(userTableDefinitions.createdAt) + .orderBy(...listOrderBy(TABLE_SORTS[sortBy], sortOrder)) const jobsByTable = await latestJobsForTables(tables.map((t) => t.id)) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index c77719e6860..6e141853a9a 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -9,8 +9,23 @@ import { workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage, getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' -import { and, eq, isNotNull, isNull, sql } from 'drizzle-orm' +import { and, eq, isNotNull, isNull, type SQL } from 'drizzle-orm' import type { ShareRecord } from '@/lib/api/contracts/public-shares' +import type { V2FileSortBy } from '@/lib/api/contracts/v2/files' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { + type CursorKey, + encodeKeyset, + INVALID_CURSOR_MESSAGE, + type KeysetKey, + keysetAfter, + keysetColumns, + listOrderBy, + numberKey, + searchFilter, + textKey, + timestampKey, +} from '@/lib/api/list-query' import { decrementStorageUsageForBillingContextInTx, incrementStorageUsageForBillingContextInTx, @@ -775,6 +790,33 @@ export async function getWorkspaceFileByName( return mapSingleWorkspaceFileRecord(files[0], workspaceId) } +/** Workspace-file rows for one scope: live, Recently Deleted, or both. */ +function workspaceFileScopeCondition(workspaceId: string, scope: WorkspaceFileScope) { + const base = [ + eq(workspaceFiles.workspaceId, workspaceId), + eq(workspaceFiles.context, 'workspace'), + ] + if (scope === 'all') return and(...base) + return scope === 'archived' + ? and(...base, isNotNull(workspaceFiles.deletedAt)) + : and(...base, isNull(workspaceFiles.deletedAt)) +} + +/** Resolves `folderPath` for a page of rows, reading the folder tree only if any row needs it. */ +async function hydrateWorkspaceFilePaths( + files: (typeof workspaceFiles.$inferSelect)[], + workspaceId: string, + options?: { folders?: WorkspaceFileFolderRecord[]; hydrateFolderPaths?: boolean } +): Promise { + const needsFolderPaths = + files.some((file) => file.folderId) && (options?.hydrateFolderPaths ?? true) + const folders = needsFolderPaths + ? (options?.folders ?? (await listWorkspaceFileFolders(workspaceId, { scope: 'all' }))) + : [] + const folderPaths = needsFolderPaths ? buildWorkspaceFileFolderPathMap(folders) : new Map() + return files.map((file) => mapWorkspaceFileRecord(file, workspaceId, folderPaths)) +} + /** * List all files for a workspace */ @@ -783,37 +825,14 @@ export async function listWorkspaceFiles( options?: ListWorkspaceFilesOptions ): Promise { try { - const { scope = 'active', hydrateFolderPaths = true } = options ?? {} + const { scope = 'active' } = options ?? {} const files = await db .select() .from(workspaceFiles) - .where( - scope === 'all' - ? and( - eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.context, 'workspace') - ) - : scope === 'archived' - ? and( - eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.context, 'workspace'), - sql`${workspaceFiles.deletedAt} IS NOT NULL` - ) - : and( - eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.context, 'workspace'), - isNull(workspaceFiles.deletedAt) - ) - ) + .where(workspaceFileScopeCondition(workspaceId, scope)) .orderBy(workspaceFiles.uploadedAt) - const needsFolderPaths = files.some((file) => file.folderId) && hydrateFolderPaths - const folders = needsFolderPaths - ? (options?.folders ?? (await listWorkspaceFileFolders(workspaceId, { scope: 'all' }))) - : [] - const folderPaths = needsFolderPaths ? buildWorkspaceFileFolderPathMap(folders) : new Map() - - return files.map((file) => mapWorkspaceFileRecord(file, workspaceId, folderPaths)) + return hydrateWorkspaceFilePaths(files, workspaceId, options) } catch (error) { logger.error(`Failed to list workspace files for ${workspaceId}:`, error) if (options?.throwOnError) throw error @@ -821,6 +840,91 @@ export async function listWorkspaceFiles( } } +/** + * The keysets behind {@link queryWorkspaceFiles}' sortable fields. `satisfies` + * makes this total over the contract enum: a new sortable field in the contract + * fails to compile until it has a keyset here, rather than silently falling + * through to an unordered scan. + * + * Every key column is `NOT NULL`, and `id` closes each keyset so a page + * boundary inside a run of equal names/sizes/timestamps is still stable. + */ +const fileId = textKey(workspaceFiles.id, (row) => row.id) + +const WORKSPACE_FILE_SORTS = { + name: [textKey(workspaceFiles.originalName, (row) => row.name), fileId], + size: [numberKey(workspaceFiles.size, (row) => row.size), fileId], + uploadedAt: [timestampKey(workspaceFiles.uploadedAt, (row) => row.uploadedAt), fileId], + updatedAt: [timestampKey(workspaceFiles.updatedAt, (row) => row.updatedAt), fileId], +} satisfies Record[]> + +export interface QueryWorkspaceFilesOptions { + scope?: WorkspaceFileScope + /** Restrict to one file folder. */ + folderId?: string + /** Case-insensitive substring match on the file name. */ + search?: string + sortBy: V2FileSortBy + sortOrder: V2SortOrder + limit: number + /** Keyset values from a cursor, in the sort's key order. */ + after?: CursorKey[] +} + +export interface QueryWorkspaceFilesResult { + files: WorkspaceFileRecord[] + /** Keyset values to resume from, or `null` when this page is the last one. */ + nextKeys: CursorKey[] | null +} + +/** + * One filtered, sorted, bounded page of a workspace's files. + * + * Distinct from {@link listWorkspaceFiles}, which materializes the whole scope + * for callers that genuinely need it. Here the filter, the ordering, and the + * slice are all in the query: a name search must not become "read every row, + * then discard almost all of them in JS". + * + * Throws rather than returning a short page — a swallowed storage error here is + * indistinguishable from "no more results" and would silently end pagination. + * A cursor that does not fit the requested sort is a classified `validation` + * failure, so the route renders it as a 400 rather than a 500. + */ +export async function queryWorkspaceFiles( + workspaceId: string, + options: QueryWorkspaceFilesOptions +): Promise { + const { scope = 'active', folderId, search, sortBy, sortOrder, limit, after } = options + const keys: readonly KeysetKey[] = WORKSPACE_FILE_SORTS[sortBy] + + let resumeAfter: SQL | undefined + if (after) { + const condition = keysetAfter(keys, after, sortOrder) + if (!condition) throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) + resumeAfter = condition + } + + const conditions = [ + workspaceFileScopeCondition(workspaceId, scope), + folderId ? eq(workspaceFiles.folderId, folderId) : undefined, + searchFilter(workspaceFiles.originalName, search), + resumeAfter, + ] + + const rows = await db + .select() + .from(workspaceFiles) + .where(and(...conditions)) + .orderBy(...listOrderBy(keysetColumns(keys), sortOrder)) + .limit(limit + 1) + + const hasMore = rows.length > limit + const files = await hydrateWorkspaceFilePaths(rows.slice(0, limit), workspaceId) + const last = files.at(-1) + + return { files, nextKeys: hasMore && last ? encodeKeyset(keys, last) : null } +} + /** * Normalize a workspace file reference to either a display name or canonical file ID. * Supports raw IDs, `files/{name}`, `files/{name}/content`, and `files/{name}/meta.json`. diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-query.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-query.test.ts new file mode 100644 index 00000000000..21b5be978f6 --- /dev/null +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-query.test.ts @@ -0,0 +1,206 @@ +/** + * @vitest-environment node + * + * `queryWorkspaceFiles` — the paged, filtered, sorted read behind + * `GET /api/v2/files`. The assertions are on the query it builds, because the + * point of this function existing is that the scope filter, the name search, + * the ordering, and the page slice all happen in SQL rather than over a + * full-workspace result. + */ +import { + dbChainMockFns, + flattenMockConditions, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/billing/storage', () => ({ + decrementStorageUsageForBillingContextInTx: vi.fn(), + incrementStorageUsageForBillingContextInTx: vi.fn(), + maybeNotifyStorageLimitForBillingContext: vi.fn(), + resolveStorageBillingContext: vi.fn(), +})) + +vi.mock('@/lib/uploads', () => ({ + getServePathPrefix: vi.fn(() => '/api/files/serve/s3/'), +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + deleteFile: vi.fn(), + downloadFile: vi.fn(), + hasCloudStorage: vi.fn(() => false), + headObject: vi.fn(), + uploadFile: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ + assertWorkspaceFileFolderTarget: vi.fn(async () => null), + buildWorkspaceFileFolderPathMap: vi.fn(() => new Map()), + fileNameExistsInWorkspaceFolder: vi.fn(async () => false), + findWorkspaceFileFolderIdByPath: vi.fn(), + getWorkspaceFileFolderPath: vi.fn(), + listWorkspaceFileFolders: vi.fn(async () => []), + normalizeWorkspaceFileItemName: vi.fn((name: string) => name), +})) + +import { queryWorkspaceFiles } from '@/lib/uploads/contexts/workspace/workspace-file-manager' + +const WS = 'workspace-1' + +const DEFAULTS = { sortBy: 'uploadedAt', sortOrder: 'asc', limit: 100 } as const + +function buildRow(overrides: Record = {}) { + return { + id: 'wf_1', + key: 'workspace/ws/1-x-data.csv', + userId: 'user-1', + workspaceId: WS, + folderId: null, + context: 'workspace', + originalName: 'data.csv', + contentType: 'text/csv', + size: 1024, + deletedAt: null, + uploadedAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + contentUpdatedAt: new Date('2024-01-01T00:00:00Z'), + ...overrides, + } +} + +const lastConditions = () => + flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]).filter(Boolean) + +const lastOrderBy = () => dbChainMockFns.orderBy.mock.calls.at(-1) ?? [] + +describe('queryWorkspaceFiles', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('narrows the query with a case-insensitive substring match on the file name', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await queryWorkspaceFiles(WS, { ...DEFAULTS, search: 'data' }) + + expect(lastConditions().find((c) => c.type === 'ilike')).toMatchObject({ + column: schemaMock.workspaceFiles.originalName, + pattern: '%data%', + }) + }) + + it('escapes LIKE wildcards in the search term', async () => { + queueTableRows(schemaMock.workspaceFiles, []) + + await queryWorkspaceFiles(WS, { ...DEFAULTS, search: '50%_off' }) + + expect(lastConditions().find((c) => c.type === 'ilike')).toMatchObject({ + pattern: '%50\\%\\_off%', + }) + }) + + it('adds no search condition when the caller did not search', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await queryWorkspaceFiles(WS, DEFAULTS) + + expect(lastConditions().some((c) => c.type === 'ilike')).toBe(false) + }) + + it('filters to one folder in the query', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await queryWorkspaceFiles(WS, { ...DEFAULTS, folderId: 'fold_1' }) + + expect( + lastConditions().some( + (c) => + c.type === 'eq' && c.left === schemaMock.workspaceFiles.folderId && c.right === 'fold_1' + ) + ).toBe(true) + }) + + it('orders by the requested field, with id closing the keyset', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await queryWorkspaceFiles(WS, { ...DEFAULTS, sortBy: 'name', sortOrder: 'desc' }) + + expect(lastOrderBy()).toEqual([ + { type: 'desc', column: schemaMock.workspaceFiles.originalName }, + { type: 'desc', column: schemaMock.workspaceFiles.id }, + ]) + }) + + it('bounds the page in SQL by fetching one row past the limit', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await queryWorkspaceFiles(WS, { ...DEFAULTS, limit: 25 }) + + expect(dbChainMockFns.limit).toHaveBeenLastCalledWith(26) + }) + + it('reports no further keys when the page is not full, terminating pagination', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + const { files, nextKeys } = await queryWorkspaceFiles(WS, { ...DEFAULTS, limit: 10 }) + + expect(files).toHaveLength(1) + expect(nextKeys).toBeNull() + }) + + it('returns the keyset of the last row when more results exist', async () => { + queueTableRows(schemaMock.workspaceFiles, [ + buildRow(), + buildRow({ id: 'wf_2', originalName: 'b.csv' }), + ]) + + const { files, nextKeys } = await queryWorkspaceFiles(WS, { + ...DEFAULTS, + sortBy: 'name', + limit: 1, + }) + + expect(files.map((f) => f.id)).toEqual(['wf_1']) + expect(nextKeys).toEqual(['data.csv', 'wf_1']) + }) + + it('resumes strictly after the cursor keys, alongside the filter', async () => { + queueTableRows(schemaMock.workspaceFiles, [ + buildRow({ id: 'wf_2', originalName: 'data-2.csv' }), + ]) + + await queryWorkspaceFiles(WS, { + ...DEFAULTS, + sortBy: 'name', + search: 'data', + after: ['data.csv', 'wf_1'], + }) + + const conditions = lastConditions() + expect(conditions.find((c) => c.type === 'ilike')).toMatchObject({ pattern: '%data%' }) + expect(conditions.some((c) => c.type === 'or')).toBe(true) + }) + + /** + * Cursor contents are caller-controlled, so a value the key cannot hold is a + * client error. Classified `validation` so the route renders 400, not 500. + */ + it('rejects a cursor whose values do not fit the sort', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await expect( + queryWorkspaceFiles(WS, { ...DEFAULTS, sortBy: 'uploadedAt', after: ['not-a-date', 'wf_1'] }) + ).rejects.toMatchObject({ code: 'validation' }) + }) + + it('rejects a cursor with the wrong number of keys for the sort', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await expect( + queryWorkspaceFiles(WS, { ...DEFAULTS, sortBy: 'name', after: ['data.csv'] }) + ).rejects.toMatchObject({ code: 'validation' }) + }) +}) diff --git a/apps/sim/lib/workflows/custom-tools/operations.ts b/apps/sim/lib/workflows/custom-tools/operations.ts index 2b6a779776b..32e2ba8bf3c 100644 --- a/apps/sim/lib/workflows/custom-tools/operations.ts +++ b/apps/sim/lib/workflows/custom-tools/operations.ts @@ -2,7 +2,10 @@ import { db } from '@sim/db' import { customTools } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' -import { and, desc, eq, isNull, or } from 'drizzle-orm' +import { and, type Column, desc, eq, isNull, or } from 'drizzle-orm' +import type { V2CustomToolSortBy } from '@/lib/api/contracts/v2/custom-tools' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' import { generateRequestId } from '@/lib/core/utils/request' const logger = createLogger('CustomToolsOperations') @@ -136,12 +139,35 @@ export async function listCustomTools(params: { userId: string; workspaceId?: st * scoped in every direction, so it uses these instead — a caller holding a * workspace key must never reach another user's personal tool. */ -export async function listWorkspaceCustomTools(params: { workspaceId: string }) { +/** + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `id` so tools sharing a timestamp still come + * back in a stable order. + */ +const CUSTOM_TOOL_SORTS = { + title: [customTools.title, customTools.id], + createdAt: [customTools.createdAt, customTools.id], + updatedAt: [customTools.updatedAt, customTools.id], +} satisfies Record + +export async function listWorkspaceCustomTools(params: { + workspaceId: string + /** Case-insensitive substring match on the tool title. */ + search?: string + sortBy?: V2CustomToolSortBy + sortOrder?: V2SortOrder +}) { + const { sortBy = 'createdAt', sortOrder = 'desc' } = params return db .select() .from(customTools) - .where(eq(customTools.workspaceId, params.workspaceId)) - .orderBy(desc(customTools.createdAt)) + .where( + and( + eq(customTools.workspaceId, params.workspaceId), + searchFilter(customTools.title, params.search) + ) + ) + .orderBy(...listOrderBy(CUSTOM_TOOL_SORTS[sortBy], sortOrder)) } export async function getWorkspaceCustomTool(params: { workspaceId: string; toolId: string }) { diff --git a/apps/sim/lib/workflows/skills/operations.ts b/apps/sim/lib/workflows/skills/operations.ts index 4746cc0d5f7..daf82b2d285 100644 --- a/apps/sim/lib/workflows/skills/operations.ts +++ b/apps/sim/lib/workflows/skills/operations.ts @@ -2,7 +2,10 @@ import { db } from '@sim/db' import { skill, skillMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId, generateShortId } from '@sim/utils/id' -import { and, desc, eq, ne } from 'drizzle-orm' +import { and, type Column, desc, eq, ne } from 'drizzle-orm' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import type { V2SkillSortBy } from '@/lib/api/contracts/v2/skills' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' import { generateRequestId } from '@/lib/core/utils/request' import { getEditableSkillIds } from '@/lib/skills/access' import { @@ -32,6 +35,32 @@ function builtinSkillRow(workspaceId: string, builtin: BuiltinSkill): typeof ski } } +type SkillRow = typeof skill.$inferSelect + +/** + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `id` so skills sharing a timestamp still + * come back in a stable order. + */ +const SKILL_SORTS = { + name: [skill.name, skill.id], + createdAt: [skill.createdAt, skill.id], + updatedAt: [skill.updatedAt, skill.id], +} satisfies Record + +/** The sort key {@link SKILL_SORTS} orders on, for one row. */ +function skillSortKey(row: SkillRow, sortBy: V2SkillSortBy): [string | number, string] { + if (sortBy === 'name') return [row.name, row.id] + return [(sortBy === 'createdAt' ? row.createdAt : row.updatedAt).getTime(), row.id] +} + +function compareSkills(a: SkillRow, b: SkillRow, sortBy: V2SkillSortBy): number { + const [aKey, aId] = skillSortKey(a, sortBy) + const [bKey, bId] = skillSortKey(b, sortBy) + if (aKey !== bKey) return aKey < bKey ? -1 : 1 + return aId < bId ? -1 : aId > bId ? 1 : 0 +} + /** * List skills for a workspace, ordered by createdAt desc. Built-in template * skills are prepended (they live in code, not the DB) so they appear wherever @@ -40,23 +69,53 @@ function builtinSkillRow(workspaceId: string, builtin: BuiltinSkill): typeof ski * Pass `includeBuiltins: false` to return only user-created skills. The * mothership uses this for the workspace skill inventory it sees, which lists * only user-created skills and never the code-only templates. + * + * `search` and `sort` serve the public list. The DB half of both runs in the + * query; the built-ins are a small code constant with no row to order, so they + * are filtered and merged in memory — the one place a v2 list cannot push its + * sort all the way down. Passing `sort` also re-orders the built-ins into the + * requested order instead of pinning them first, so the public list is sorted + * as documented; callers that omit it keep the historical builtins-first order. + * + * The merged ordering compares names with JS string order rather than the + * database collation. Only the handful of ASCII built-in names are placed by + * it, so the two agree in practice. */ -export async function listSkills(params: { workspaceId: string; includeBuiltins?: boolean }) { +export async function listSkills(params: { + workspaceId: string + includeBuiltins?: boolean + /** Case-insensitive substring match on the skill name. */ + search?: string + sort?: { sortBy: V2SkillSortBy; sortOrder: V2SortOrder } +}): Promise { + const sortBy = params.sort?.sortBy ?? 'createdAt' + const sortOrder = params.sort?.sortOrder ?? 'desc' + const dbRows = await db .select() .from(skill) - .where(eq(skill.workspaceId, params.workspaceId)) - .orderBy(desc(skill.createdAt)) + .where(and(eq(skill.workspaceId, params.workspaceId), searchFilter(skill.name, params.search))) + .orderBy(...listOrderBy(SKILL_SORTS[sortBy], sortOrder)) if (params.includeBuiltins === false) { return dbRows } + /** + * Restricting `dbNames` to the searched rows is safe: a DB skill only shadows + * a built-in by sharing its name, and a name that matches the search on the + * built-in matches it on the DB row too. + */ const dbNames = new Set(dbRows.map((r) => r.name.toLowerCase())) - const builtins = BUILTIN_SKILLS.filter((b) => !dbNames.has(b.name.toLowerCase())).map((b) => - builtinSkillRow(params.workspaceId, b) - ) - return [...builtins, ...dbRows] + const term = params.search?.toLowerCase() + const builtins = BUILTIN_SKILLS.filter( + (b) => !dbNames.has(b.name.toLowerCase()) && (!term || b.name.toLowerCase().includes(term)) + ).map((b) => builtinSkillRow(params.workspaceId, b)) + + if (!params.sort) return [...builtins, ...dbRows] + + const direction = sortOrder === 'asc' ? 1 : -1 + return [...builtins, ...dbRows].sort((a, b) => direction * compareSkills(a, b, sortBy)) } /** A skill row tagged with whether the caller can edit it (always false on builtins). */ diff --git a/packages/testing/src/mocks/database.mock.ts b/packages/testing/src/mocks/database.mock.ts index 455c90f66c8..c6612ef276f 100644 --- a/packages/testing/src/mocks/database.mock.ts +++ b/packages/testing/src/mocks/database.mock.ts @@ -12,6 +12,8 @@ export function createMockSql() { toSQL: () => ({ sql: strings.join('?'), params: values }), /** Mirrors drizzle's `sql``…`.as(alias)` for aliased select expressions. */ as: (alias: string) => ({ ...fragment, alias }), + /** Mirrors drizzle's `sql``…`.mapWith(Number)` result decoder. */ + mapWith: (_decoder: unknown) => fragment, } return fragment } From ca1dad861d9f7c5d8c8886dd41db20f8dad9c7c9 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 22:51:11 -0700 Subject: [PATCH 24/24] feat(api): complete the v2 workflows resource with versions and CRUD (#6184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): complete the v2 workflows resource with versions and CRUD Adds version listing/detail plus create, update, and delete to the v2 workflows surface, which previously covered only execution and deployment. - GET /api/v2/workflows/[id]/versions — cursor-paginated, newest first - GET /api/v2/workflows/[id]/versions/[version] — version + pinned state - POST /api/v2/workflows, PATCH and DELETE /api/v2/workflows/[id] All six delegate to the existing orchestration and persistence helpers; no new domain logic. * fix(api): check folder containment before lock state; reject malformed version cursors assertFolderMutable walks a folder's ancestor chain without filtering on workspace, so inspecting it before containment let a caller tell a locked folder in someone else's workspace (423) from a nonexistent one (400). Create and update now assert containment first, matching the ordering import-workflow.ts already uses. A version cursor that decodes to JSON without a numeric version filtered every row out and returned an empty page with nextCursor null, which reads as a clean end-of-list. Malformed cursors are now a 400. * refactor(api): page workflow versions in the persistence helper listWorkflowVersions read every version row and the route filtered and sliced the result in memory, so the response was bounded but the query was not. It now takes optional limit/afterVersion, turning the cursor into a real keyset query; the route asks for limit + 1 and only trims the has-more probe. Both params are optional, so the internal, v1 admin, and copilot callers are unchanged. Also restores the untouched GET handler in [id]/route.ts to its original formatting — collapsing its signature had re-indented the whole body and buried the actual additions in whitespace churn. --- apps/docs/openapi-v2-workflows.json | 765 +++++++++++++++++- apps/sim/app/api/v1/middleware.ts | 2 + .../app/api/v2/workflows/[id]/route.test.ts | 355 ++++++++ apps/sim/app/api/v2/workflows/[id]/route.ts | 173 +++- .../[id]/versions/[version]/route.test.ts | 154 ++++ .../[id]/versions/[version]/route.ts | 74 ++ .../v2/workflows/[id]/versions/route.test.ts | 221 +++++ .../api/v2/workflows/[id]/versions/route.ts | 111 +++ apps/sim/app/api/v2/workflows/route.test.ts | 216 ++++- apps/sim/app/api/v2/workflows/route.ts | 85 ++ apps/sim/lib/api/contracts/deployments.ts | 2 +- apps/sim/lib/api/contracts/v2/workflows.ts | 144 ++++ apps/sim/lib/workflows/persistence/utils.ts | 53 +- scripts/check-api-validation-contracts.ts | 4 +- 14 files changed, 2300 insertions(+), 59 deletions(-) create mode 100644 apps/sim/app/api/v2/workflows/[id]/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/versions/route.ts diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 0f7cb0a95b6..40984613cbb 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Sim API v2 — Workflows", - "description": "Version 2 of the Sim REST API for listing workflows, inspecting workflow detail, and managing 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.", + "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", @@ -23,7 +23,7 @@ "tags": [ { "name": "Workflows", - "description": "List workflows, inspect workflow detail, and manage deployments (deploy, undeploy, rollback) on the v2 API." + "description": "Create, list, inspect, update, and delete workflows, enumerate their deployment versions, and manage deployments (deploy, undeploy, rollback) on the v2 API." } ], "security": [ @@ -188,30 +188,546 @@ "$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" + }, + "examples": { + "minimal": { + "summary": "At the workspace root", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "Customer Support Agent" + } + }, + "inFolder": { + "summary": "Inside a folder, with a description", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "Customer Support Agent", + "description": "Routes incoming support tickets and drafts responses", + "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + } + } + } + } + } + }, + "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" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer Support Agent", + "description": "Routes incoming support tickets and drafts responses", + "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": false, + "deployedAt": null, + "runCount": 0, + "lastRunAt": null, + "createdAt": "2026-06-29T21:30:00.000Z", + "updatedAt": "2026-06-29T21:30:00.000Z" + } + } + } + } + }, + "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" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer Support Agent", + "description": "Routes incoming support tickets and drafts responses", + "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "runCount": 142, + "lastRunAt": "2026-06-20T14:15:22.000Z", + "variables": { + "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60": { + "id": "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60", + "name": "supportEmail", + "type": "string", + "value": "support@example.com" + } + }, + "inputs": [ + { + "name": "ticketBody", + "type": "string", + "description": "The raw text of the incoming support ticket." + } + ], + "createdAt": "2026-01-10T09:00:00.000Z", + "updatedAt": "2026-06-18T16:45:00.000Z" + } + } + } + } + }, + "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" + }, + "examples": { + "rename": { + "summary": "Rename", + "value": { + "name": "Customer Support Agent v2" + } + }, + "moveToRoot": { + "summary": "Move out of its folder to the workspace root", + "value": { + "folderId": null + } + } + } + } + } + }, + "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" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer Support Agent v2", + "description": "Routes incoming support tickets and drafts responses", + "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": false, + "deployedAt": null, + "runCount": 0, + "lastRunAt": null, + "createdAt": "2026-06-29T21:30:00.000Z", + "updatedAt": "2026-06-30T08:12:00.000Z" + } + } + } + } + }, + "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": "Archive a workflow. The workflow moves to Recently Deleted rather than being dropped, so its execution logs stay attributable, and it stops being returned by the list and detail endpoints. 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 archived.", + "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" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "deleted": true + } + } + } + } + }, + "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}": { + "/api/v2/workflows/{id}/versions": { "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).", + "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}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + "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": "The requested workflow.", + "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" @@ -230,43 +746,32 @@ "required": ["data"], "properties": { "data": { - "$ref": "#/components/schemas/WorkflowDetail" + "$ref": "#/components/schemas/WorkflowVersionDetail" } } }, "example": { "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer Support Agent", - "description": "Routes incoming support tickets and drafts responses", - "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": true, - "deployedAt": "2026-06-12T10:30:00.000Z", - "runCount": 142, - "lastRunAt": "2026-06-20T14:15:22.000Z", - "variables": { - "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60": { - "id": "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60", - "name": "supportEmail", - "type": "string", - "value": "support@example.com" - } - }, - "inputs": [ - { - "name": "ticketBody", - "type": "string", - "description": "The raw text of the incoming support ticket." - } - ], - "createdAt": "2026-01-10T09:00:00.000Z", - "updatedAt": "2026-06-18T16:45:00.000Z" + "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" }, @@ -1293,6 +1798,17 @@ "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": { @@ -1788,6 +2304,185 @@ "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" + }, + "folderId": { + "type": "string", + "minLength": 1, + "nullable": true, + "description": "Folder to create the workflow in. Omit or send `null` to create it at the workspace root.", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + } + } + }, + "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" + }, + "folderId": { + "type": "string", + "minLength": 1, + "nullable": true, + "description": "Destination folder. Send `null` to move the workflow to the workspace root.", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + } + } + }, + "DeleteWorkflowResult": { + "type": "object", + "description": "Acknowledgement that a workflow was archived.", + "required": ["id", "deleted"], + "properties": { + "id": { + "type": "string", + "description": "The archived workflow's identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "deleted": { + "type": "boolean", + "enum": [true], + "description": "Always `true` on a successful archive." + } + } + }, + "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." + } + } } }, "responses": { diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 5fb64caf1df..2f994150296 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' 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..432027d8cc9 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/route.test.ts @@ -0,0 +1,355 @@ +/** + * @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, + mockAssertFolderInWorkspace, + WorkflowLockedErrorMock, + FolderLockedErrorMock, + FolderNotFoundErrorMock, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetActiveWorkflowRecord: vi.fn(), + mockPerformUpdateWorkflow: vi.fn(), + mockPerformDeleteWorkflow: vi.fn(), + mockAssertWorkflowMutable: vi.fn(), + mockAssertFolderMutable: vi.fn(), + mockAssertFolderInWorkspace: vi.fn(), + WorkflowLockedErrorMock: class WorkflowLockedError extends Error { + status = 423 + }, + FolderLockedErrorMock: class FolderLockedError extends Error { + status = 423 + }, + FolderNotFoundErrorMock: class FolderNotFoundError extends Error { + status = 400 + }, +})) + +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, + assertFolderInWorkspace: mockAssertFolderInWorkspace, + WorkflowLockedError: WorkflowLockedErrorMock, + FolderLockedError: FolderLockedErrorMock, + FolderNotFoundError: FolderNotFoundErrorMock, +})) + +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) + mockAssertFolderInWorkspace.mockResolvedValue(undefined) + 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({ folderId: 'fld-1' }) + expect(res.status).toBe(423) + expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() + }) + + it('400s a folder outside the workspace without ever reading its lock state', async () => { + mockAssertFolderInWorkspace.mockRejectedValue( + new FolderNotFoundErrorMock('Target folder not found') + ) + const res = await callPatch({ folderId: 'fld-other-workspace' }) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + // Containment runs first, so a locked foreign folder cannot be told apart + // from a nonexistent one by its status code. + expect(mockAssertFolderMutable).not.toHaveBeenCalled() + expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() + }) + + it('checks folder containment against the workflow workspace before mutability', async () => { + const order: string[] = [] + mockAssertFolderInWorkspace.mockImplementation(async () => { + order.push('containment') + }) + mockAssertFolderMutable.mockImplementation(async () => { + order.push('mutability') + }) + + await callPatch({ folderId: 'fld-1' }) + + expect(order).toEqual(['containment', 'mutability']) + expect(mockAssertFolderInWorkspace).toHaveBeenCalledWith('fld-1', 'workspace-1') + }) + + it('skips the containment check on a rename that does not move the workflow', async () => { + await callPatch({ name: 'Support Agent v2' }) + expect(mockAssertFolderInWorkspace).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', + folderId: null, + 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 index 7698187bb7a..a3b22e05dc0 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -1,23 +1,48 @@ import { db } from '@sim/db' import { workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' +import { + assertFolderInWorkspace, + assertFolderMutable, + assertWorkflowMutable, + FolderLockedError, + FolderNotFoundError, + 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, v2GetWorkflowContract } from '@/lib/api/contracts/v2/workflows' +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 { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' +import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' 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' +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) @@ -84,3 +109,145 @@ export const GET = withRouteHandler( } } ) + +/** 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, folderId } = 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') + + /** + * 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 one that simply does not exist (400). + */ + if (folderId) await assertFolderInWorkspace(folderId, workflowData.workspaceId) + 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 + /** + * 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, + folderId: 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 FolderNotFoundError) return v2Error('BAD_REQUEST', error.message) + 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') + } +}) + +/** DELETE /api/v2/workflows/[id] — Archive a workflow into Recently Deleted. */ +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/route.test.ts b/apps/sim/app/api/v2/workflows/route.test.ts index 5c2b922e40b..6b3e0ee67c2 100644 --- a/apps/sim/app/api/v2/workflows/route.test.ts +++ b/apps/sim/app/api/v2/workflows/route.test.ts @@ -16,9 +16,26 @@ import { import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckRateLimit, mockResolveWorkspaceAccess } = vi.hoisted(() => ({ +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockPerformCreateWorkflow, + mockAssertFolderMutable, + mockAssertFolderInWorkspace, + FolderLockedErrorMock, + FolderNotFoundErrorMock, +} = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceAccess: vi.fn(), + mockPerformCreateWorkflow: vi.fn(), + mockAssertFolderMutable: vi.fn(), + mockAssertFolderInWorkspace: vi.fn(), + FolderLockedErrorMock: class FolderLockedError extends Error { + status = 423 + }, + FolderNotFoundErrorMock: class FolderNotFoundError extends Error { + status = 400 + }, })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -26,11 +43,22 @@ vi.mock('@/app/api/v1/middleware', () => ({ resolveWorkspaceAccess: mockResolveWorkspaceAccess, })) +vi.mock('@/lib/workflows/orchestration', () => ({ + performCreateWorkflow: mockPerformCreateWorkflow, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + assertFolderMutable: mockAssertFolderMutable, + assertFolderInWorkspace: mockAssertFolderInWorkspace, + FolderLockedError: FolderLockedErrorMock, + FolderNotFoundError: FolderNotFoundErrorMock, +})) + vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: vi.fn().mockResolvedValue(null), })) -import { GET } from '@/app/api/v2/workflows/route' +import { GET, POST } from '@/app/api/v2/workflows/route' const WS = 'workspace-1' @@ -210,3 +238,187 @@ describe('GET /api/v2/workflows', () => { 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) + mockAssertFolderInWorkspace.mockResolvedValue(undefined) + 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, folderId: 'fld-1' }) + expect(res.status).toBe(423) + expect((await res.json()).error.code).toBe('LOCKED') + expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() + }) + + it('400s a folder outside the workspace without ever reading its lock state', async () => { + mockAssertFolderInWorkspace.mockRejectedValue( + new FolderNotFoundErrorMock('Target folder not found') + ) + const res = await callPost({ ...VALID_BODY, folderId: 'fld-other-workspace' }) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + // Containment runs first, so a locked foreign folder cannot be told apart + // from a nonexistent one by its status code. + expect(mockAssertFolderMutable).not.toHaveBeenCalled() + expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() + }) + + it('checks folder containment before mutability', async () => { + const order: string[] = [] + mockAssertFolderInWorkspace.mockImplementation(async () => { + order.push('containment') + }) + mockAssertFolderMutable.mockImplementation(async () => { + order.push('mutability') + }) + + await callPost({ ...VALID_BODY, folderId: 'fld-1' }) + + expect(order).toEqual(['containment', 'mutability']) + expect(mockAssertFolderInWorkspace).toHaveBeenCalledWith('fld-1', 'workspace-1') + }) + + it('skips the containment check when no folder is supplied', async () => { + await callPost(VALID_BODY) + expect(mockAssertFolderInWorkspace).not.toHaveBeenCalled() + 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', + folderId: null, + 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: undefined, + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts index 0706f835c53..88d9b457264 100644 --- a/apps/sim/app/api/v2/workflows/route.ts +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -1,6 +1,12 @@ import { db } from '@sim/db' import { workflow } 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 { and, eq, isNull } from 'drizzle-orm' @@ -8,6 +14,7 @@ import type { NextRequest } from 'next/server' import { type V2WorkflowListItem, type V2WorkflowSortBy, + v2CreateWorkflowContract, v2ListWorkflowsContract, } from '@/lib/api/contracts/v2/workflows' import { @@ -23,6 +30,7 @@ import { } from '@/lib/api/list-query' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performCreateWorkflow } from '@/lib/workflows/orchestration' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { @@ -31,7 +39,9 @@ import { encodeSortedCursor, v2CursorList, v2CursorSortError, + v2Data, v2Error, + v2ErrorForOrchestration, v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, @@ -171,3 +181,78 @@ export const GET = withRouteHandler(async (request: NextRequest) => { 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, folderId } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + /** + * 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 one that simply does not exist (400). + */ + if (folderId) await assertFolderInWorkspace(folderId, workspaceId) + await assertFolderMutable(folderId ?? null) + + const result = await performCreateWorkflow({ + userId, + workspaceId, + name, + description, + 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, + folderId: created.folderId ?? null, + 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 FolderNotFoundError) return v2Error('BAD_REQUEST', error.message) + 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/lib/api/contracts/deployments.ts b/apps/sim/lib/api/contracts/deployments.ts index db8e82adb6b..1d87bfe4edb 100644 --- a/apps/sim/lib/api/contracts/deployments.ts +++ b/apps/sim/lib/api/contracts/deployments.ts @@ -8,7 +8,7 @@ import { } from '@/lib/workflows/deployment-lifecycle' import type { WorkflowState } from '@/stores/workflows/workflow/types' -const deployedWorkflowStateSchema = z.custom( +export const deployedWorkflowStateSchema = z.custom( (value) => typeof value === 'object' && value !== null, 'Expected workflow state' ) diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 76e944480b9..119ae6ba1a0 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -1,4 +1,10 @@ import { z } from 'zod' +import { + deployedWorkflowStateSchema, + deploymentVersionParamsSchema, + deploymentVersionSchema, +} from '@/lib/api/contracts/deployments' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v1DeployWorkflowDataSchema, @@ -30,6 +36,11 @@ import { * payloads reuse the already-concrete v1 schemas, re-wrapped in * `v2DataResponse` (the v1 `limits` body field is dropped — v2 carries * rate-limit state in headers and usage on a dedicated endpoint). + * + * The create/update bodies have no v1 counterpart and are v2-native: they carry + * only the fields a public caller owns (name, description, folder placement). + * `sortOrder`, `locked`, and `forkSyncExcluded` are workspace-UI concerns and + * are not part of the public surface. */ /** @@ -123,6 +134,139 @@ export const v2GetWorkflowContract = defineRouteContract({ }, }) +/** + * Create body. `workspaceId` is required — personal (workspace-less) workflows + * are not creatable on any surface. Name collisions inside the target folder + * are a 409 rather than being silently deduplicated: a public caller that asked + * for a name should learn it was taken, not discover "My Agent (2)" later. + */ +export const v2CreateWorkflowBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), + description: z.string().max(50_000, 'description is too long').nullable().optional(), + /** Explicit `null` (or omission) creates the workflow at the workspace root. */ + folderId: z.string().min(1, 'folderId cannot be empty').nullable().optional(), + }) + .strict() +export type V2CreateWorkflowBody = z.input + +/** Update body. Omitted fields keep their stored values. */ +export const v2UpdateWorkflowBodySchema = z + .object({ + name: z.string().trim().min(1, 'name cannot be empty').max(255, 'name is too long').optional(), + description: z.string().max(50_000, 'description is too long').nullable().optional(), + folderId: z.string().min(1, 'folderId cannot be empty').nullable().optional(), + }) + .strict() + .superRefine((body, ctx) => { + if (body.name === undefined && body.description === undefined && body.folderId === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['name'], + message: 'At least one of name, description, or folderId is required', + }) + } + }) +export type V2UpdateWorkflowBody = z.input + +/** + * Delete acknowledgement. Deletion archives the workflow (it lands in Recently + * Deleted) rather than dropping its rows, so runs and logs stay attributable. + */ +export const v2DeleteWorkflowDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) +export type V2DeleteWorkflowData = z.output + +export const v2CreateWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows', + body: v2CreateWorkflowBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowListItemSchema), + }, +}) + +export const v2UpdateWorkflowContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/workflows/[id]', + params: workflowIdParamsSchema, + body: v2UpdateWorkflowBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowListItemSchema), + }, +}) + +export const v2DeleteWorkflowContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/workflows/[id]', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteWorkflowDataSchema), + }, +}) + +/** + * A deployment version as the public surface sees it: the internal row minus + * `createdBy`, which is a raw user id with no public resolution path — + * `deployedBy` already carries the human-readable name. + */ +export const v2WorkflowVersionSchema = deploymentVersionSchema.omit({ createdBy: true }) +export type V2WorkflowVersion = z.output + +/** + * Version listing is cursor-paginated: a workflow accrues one version per + * deploy and nothing prunes them, so the set is unbounded. The cursor is keyed + * on the version number, which is dense and strictly descending. + */ +export const v2ListWorkflowVersionsQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).optional().default(50), + cursor: z.string().optional(), +}) +export type V2ListWorkflowVersionsQuery = z.output + +/** + * A single version plus the workflow state it pins. `state` is the deployed + * graph snapshot — the same portable blob the internal deployment reader + * serves — and is the thing a caller diffs before rolling back to it. + */ +export const v2WorkflowVersionDetailSchema = z.object({ + id: z.string(), + version: z.number().int().positive(), + name: z.string().nullable(), + description: z.string().nullable(), + isActive: z.boolean(), + createdAt: z.string(), + state: deployedWorkflowStateSchema, +}) +export type V2WorkflowVersionDetail = z.output + +export const v2ListWorkflowVersionsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[id]/versions', + params: workflowIdParamsSchema, + query: v2ListWorkflowVersionsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2WorkflowVersionSchema), + }, +}) + +export const v2GetWorkflowVersionContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[id]/versions/[version]', + params: deploymentVersionParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowVersionDetailSchema), + }, +}) + export const v2DeployWorkflowContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows/[id]/deploy', diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index 584d96cc566..4b72877d086 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -937,7 +937,21 @@ export async function getWorkflowDeploymentVersion( return row ?? null } -export async function listWorkflowVersions(workflowId: string): Promise<{ +export interface ListWorkflowVersionsOptions { + /** Caps the rows read. Omitted reads every version. */ + limit?: number + /** + * Keyset bound for the `version DESC` ordering: returns only versions + * strictly below this number, i.e. the page *after* it. Paired with `limit` + * this keeps a paginated caller off a full-table read. + */ + afterVersion?: number +} + +export async function listWorkflowVersions( + workflowId: string, + options: ListWorkflowVersionsOptions = {} +): Promise<{ versions: Array<{ id: string version: number @@ -952,22 +966,29 @@ export async function listWorkflowVersions(workflowId: string): Promise<{ }> { const { user } = await import('@sim/db') + const versionConditions = [eq(workflowDeploymentVersion.workflowId, workflowId)] + if (options.afterVersion !== undefined) { + versionConditions.push(lt(workflowDeploymentVersion.version, options.afterVersion)) + } + + const versionQuery = db + .select({ + id: workflowDeploymentVersion.id, + version: workflowDeploymentVersion.version, + name: workflowDeploymentVersion.name, + description: workflowDeploymentVersion.description, + isActive: workflowDeploymentVersion.isActive, + createdAt: workflowDeploymentVersion.createdAt, + createdBy: workflowDeploymentVersion.createdBy, + deployedByName: user.name, + }) + .from(workflowDeploymentVersion) + .leftJoin(user, eq(workflowDeploymentVersion.createdBy, user.id)) + .where(and(...versionConditions)) + .orderBy(desc(workflowDeploymentVersion.version)) + const [rows, [currentOperation]] = await Promise.all([ - db - .select({ - id: workflowDeploymentVersion.id, - version: workflowDeploymentVersion.version, - name: workflowDeploymentVersion.name, - description: workflowDeploymentVersion.description, - isActive: workflowDeploymentVersion.isActive, - createdAt: workflowDeploymentVersion.createdAt, - createdBy: workflowDeploymentVersion.createdBy, - deployedByName: user.name, - }) - .from(workflowDeploymentVersion) - .leftJoin(user, eq(workflowDeploymentVersion.createdBy, user.id)) - .where(eq(workflowDeploymentVersion.workflowId, workflowId)) - .orderBy(desc(workflowDeploymentVersion.version)), + options.limit !== undefined ? versionQuery.limit(options.limit) : versionQuery, /** * Only the workflow's current (latest-generation) operation carries a * status marker: a failed or in-flight attempt is live information until diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index c52c7f59a55..f8a7a617938 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1046, - zodRoutes: 1046, + totalRoutes: 1048, + zodRoutes: 1048, nonZodRoutes: 0, } as const