diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 0b3704a9736..052f93564d2 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -123,6 +123,9 @@ jobs: - name: API contract boundary audit run: bun run check:api-validation:strict + - name: OpenAPI spec validation + run: bun run check:openapi + - name: Desktop bridge contract audit run: bun run check:desktop-bridge diff --git a/apps/docs/content/docs/de/api-reference/getting-started.mdx b/apps/docs/content/docs/de/api-reference/getting-started.mdx index 25c8cfdbf2e..7e94ab0d7bd 100644 --- a/apps/docs/content/docs/de/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/de/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/de/api-reference/meta.json b/apps/docs/content/docs/de/api-reference/meta.json index d8a1fb142c6..d2e994fef8e 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,18 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", "(generated)/audit-logs", "(generated)/tables", - "(generated)/files" + "(generated)/files", + "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/folders", + "(generated)/credentials", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json b/apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json new file mode 100644 index 00000000000..52458d430c3 --- /dev/null +++ b/apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json @@ -0,0 +1,3 @@ +{ + "pages": ["executeWorkflow", "getWorkflowExecution", "cancelExecution", "getJobStatus"] +} diff --git a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json index 8e2caa1abe8..d5e28d23d63 100644 --- a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json +++ b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json @@ -1,8 +1,5 @@ { "pages": [ - "executeWorkflow", - "getWorkflowExecution", - "cancelExecution", "listWorkflows", "getWorkflow", "exportWorkflow", @@ -10,6 +7,8 @@ "deployWorkflow", "undeployWorkflow", "rollbackWorkflow", - "getJobStatus" + "executeWorkflowV2", + "getWorkflowExecutionV2", + "cancelExecutionV2" ] } diff --git a/apps/docs/content/docs/en/api-reference/getting-started.mdx b/apps/docs/content/docs/en/api-reference/getting-started.mdx index 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/en/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/en/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/en/api-reference/meta.json b/apps/docs/content/docs/en/api-reference/meta.json index c99ab8eb13f..d2e994fef8e 100644 --- a/apps/docs/content/docs/en/api-reference/meta.json +++ b/apps/docs/content/docs/en/api-reference/meta.json @@ -10,12 +10,19 @@ "typescript", "---Endpoints---", "(generated)/workflows", - "(generated)/human-in-the-loop", "(generated)/logs", - "(generated)/usage", "(generated)/audit-logs", "(generated)/tables", "(generated)/files", - "(generated)/knowledge-bases" + "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/folders", + "(generated)/credentials", + "---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..d2e994fef8e 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,18 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/folders", + "(generated)/credentials", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/content/docs/fr/api-reference/getting-started.mdx b/apps/docs/content/docs/fr/api-reference/getting-started.mdx index 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/fr/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/fr/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/fr/api-reference/meta.json b/apps/docs/content/docs/fr/api-reference/meta.json index c96dc5d2edc..d2e994fef8e 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,18 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/folders", + "(generated)/credentials", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/content/docs/ja/api-reference/getting-started.mdx b/apps/docs/content/docs/ja/api-reference/getting-started.mdx index 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/ja/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/ja/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/ja/api-reference/meta.json b/apps/docs/content/docs/ja/api-reference/meta.json index c96dc5d2edc..d2e994fef8e 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,18 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/folders", + "(generated)/credentials", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/content/docs/zh/api-reference/getting-started.mdx b/apps/docs/content/docs/zh/api-reference/getting-started.mdx index 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/zh/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/zh/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/zh/api-reference/meta.json b/apps/docs/content/docs/zh/api-reference/meta.json index c96dc5d2edc..d2e994fef8e 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,18 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/folders", + "(generated)/credentials", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/lib/openapi.ts b/apps/docs/lib/openapi.ts index af5f7a2b4c8..c3dac25a837 100644 --- a/apps/docs/lib/openapi.ts +++ b/apps/docs/lib/openapi.ts @@ -2,8 +2,18 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { createOpenAPI } from 'fumadocs-openapi/server' +const SPEC_FILES = [ + 'openapi-core.json', + 'openapi-v2-logs.json', + 'openapi-v2-workflows.json', + 'openapi-v2-tables.json', + 'openapi-v2-knowledge.json', + 'openapi-v2-files-audit.json', + 'openapi-v2-resources.json', +] as const + export const openapi = createOpenAPI({ - input: ['./openapi.json'], + input: SPEC_FILES.map((file) => `./${file}`), }) interface OpenAPIOperation { @@ -24,20 +34,34 @@ function resolveRef(ref: string, spec: Record): unknown { return current } -function resolveRefs(obj: unknown, spec: Record, depth = 0): unknown { - if (depth > 10) return obj +function resolveRefs( + obj: unknown, + spec: Record, + seen: Set = new Set(), + depth = 0 +): unknown { + // Generous backstop against pathological fan-out; real schemas nest far shallower. + if (depth > 50) return obj if (Array.isArray(obj)) { - return obj.map((item) => resolveRefs(item, spec, depth + 1)) + return obj.map((item) => resolveRefs(item, spec, seen, depth + 1)) } if (obj && typeof obj === 'object') { const record = obj as Record - if ('$ref' in record && typeof record.$ref === 'string') { - const resolved = resolveRef(record.$ref, spec) - return resolveRefs(resolved, spec, depth + 1) + if (typeof record.$ref === 'string') { + const ref = record.$ref + // Break reference cycles: if this $ref is already being expanded above us, + // leave it untouched instead of recursing forever. + if (seen.has(ref)) return record + const resolved = resolveRef(ref, spec) + if (resolved === undefined) return record + seen.add(ref) + const out = resolveRefs(resolved, spec, seen, depth + 1) + seen.delete(ref) + return out } const result: Record = {} for (const [key, value] of Object.entries(record)) { - result[key] = resolveRefs(value, spec, depth + 1) + result[key] = resolveRefs(value, spec, seen, depth + 1) } return result } @@ -48,14 +72,34 @@ function formatSchema(schema: unknown): string { return JSON.stringify(schema, null, 2) } -let cachedSpec: Record | null = null +let cachedSpecs: Record[] | null = null + +function getSpecs(): Record[] { + if (!cachedSpecs) { + cachedSpecs = SPEC_FILES.map( + (file) => + JSON.parse(readFileSync(join(process.cwd(), file), 'utf8')) as Record + ) + } + return cachedSpecs +} -function getSpec(): Record { - if (!cachedSpec) { - const specPath = join(process.cwd(), 'openapi.json') - cachedSpec = JSON.parse(readFileSync(specPath, 'utf8')) as Record +/** + * Locate an operation by path + method across every rendered spec, returning the + * operation together with the spec that owns it so `$ref`s resolve within the + * correct document (each spec carries its own `components`). + */ +function findOperation( + path: string, + method: string +): { operation: Record; spec: Record } | undefined { + const key = method.toLowerCase() + for (const spec of getSpecs()) { + const pathObj = (spec.paths as Record> | undefined)?.[path] + const operation = pathObj?.[key] as Record | undefined + if (operation) return { operation, spec } } - return cachedSpec + return undefined } export function getApiSpecContent( @@ -63,22 +107,19 @@ export function getApiSpecContent( description: string | undefined, operations: OpenAPIOperation[] ): string { - const spec = getSpec() - if (!operations || operations.length === 0) { return `# ${title}\n\n${description || ''}` } const op = operations[0] const method = op.method.toUpperCase() - const pathObj = (spec.paths as Record>)?.[op.path] - const operation = pathObj?.[op.method.toLowerCase()] as Record | undefined + const found = findOperation(op.path, op.method) - if (!operation) { + if (!found) { return `# ${title}\n\n${description || ''}` } - const resolved = resolveRefs(operation, spec) as Record + const resolved = resolveRefs(found.operation, found.spec) as Record const lines: string[] = [] lines.push(`# ${title}`) diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json new file mode 100644 index 00000000000..d5d3cccd20e --- /dev/null +++ b/apps/docs/openapi-core.json @@ -0,0 +1,2201 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API — Execution & Usage", + "description": "Run workflows, poll and cancel executions, resume Human-in-the-Loop pauses, and check usage limits.", + "version": "1.0.0", + "contact": { + "name": "Sim Support", + "email": "help@sim.ai", + "url": "https://www.sim.ai" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Execution", + "description": "Run workflows, poll execution status, and cancel runs" + }, + { + "name": "Human in the Loop", + "description": "Manage paused workflow executions and resume them with input" + }, + { + "name": "Usage", + "description": "Check rate limits and billing usage" + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/workflows/{id}/execute": { + "post": { + "operationId": "executeWorkflow", + "summary": "Execute Workflow", + "description": "Execute a deployed workflow. Supports synchronous, asynchronous, and streaming modes. For async execution, the response includes a statusUrl you can poll for results.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/workflows/{id}/execute\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"input\": {\n \"key\": \"value\"\n }\n }'" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the deployed workflow to execute.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + } + ], + "requestBody": { + "description": "Execution configuration including input values and execution mode options.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "input": { + "type": "object", + "description": "Key-value pairs matching the workflow's defined input fields. Use the Get Workflow endpoint to discover available input fields.", + "additionalProperties": true + }, + "triggerType": { + "type": "string", + "description": "How this execution was triggered. Defaults to api when called via the REST API. Recorded in execution logs for filtering." + }, + "stream": { + "type": "boolean", + "description": "When true, returns results as Server-Sent Events (SSE) for real-time block-by-block output streaming." + }, + "selectedOutputs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of specific block IDs whose outputs to include in the response. When omitted, all block outputs are returned." + } + } + }, + "example": { + "input": { + "query": "What is the weather in Tokyo?" + } + } + } + } + }, + "responses": { + "200": { + "description": "Synchronous execution completed successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecutionResult" + }, + "example": { + "success": true, + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", + "output": { + "content": "The weather in Tokyo is sunny, 22°C." + }, + "error": null, + "metadata": { + "startTime": "2026-01-15T10:30:00Z", + "endTime": "2026-01-15T10:30:01Z", + "duration": 1250 + } + } + } + } + }, + "202": { + "description": "Asynchronous execution has been queued. Poll the statusUrl for results.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AsyncExecutionResult" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + } + } + }, + "/api/workflows/{id}/executions/{executionId}": { + "get": { + "operationId": "getWorkflowExecution", + "summary": "Get Execution Status", + "description": "Get the current status of a workflow execution. Returns the run's lifecycle state (`running`, `paused`, `completed`, `failed`, etc.), timing, error, and optionally per-block outputs. Designed for polling — works for any execution, including ones that pause and resume.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/workflows/{id}/executions/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + }, + { + "id": "curl-with-outputs", + "label": "cURL (with block outputs)", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/workflows/{id}/executions/{executionId}?selectedOutputs=blockId,blockId.field&includeOutput=true\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The unique identifier of the execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + }, + { + "name": "includeOutput", + "in": "query", + "required": false, + "description": "When `true` and the execution has `status: completed`, include the workflow's final output in the response.", + "schema": { + "type": "string", + "enum": ["true", "false"] + } + }, + { + "name": "selectedOutputs", + "in": "query", + "required": false, + "description": "Comma-separated block-output selectors. A bare `blockId` returns that block's full output; a dot-path like `blockId.field` or `blockId.nested.path` returns just that value. Results are returned in the `blockOutputs` map keyed by the selector string.", + "schema": { + "type": "string", + "example": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf,c1b90bce-8a82-42a5-b6a5-5762846c2eaf.waitDuration" + } + } + ], + "responses": { + "200": { + "description": "Execution status returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowExecutionStatus" + }, + "examples": { + "completed": { + "summary": "Completed run", + "value": { + "executionId": "9254f1c9-5a11-4a12-91e3-8065293f3609", + "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "status": "completed", + "trigger": "api", + "level": "info", + "startedAt": "2026-05-15T19:43:12.189Z", + "endedAt": "2026-05-15T19:45:45.224Z", + "totalDurationMs": 153035, + "paused": null, + "cost": { + "total": 0.005 + }, + "error": null, + "finalOutput": null, + "blockOutputs": null + } + }, + "paused": { + "summary": "Currently paused run", + "value": { + "executionId": "772749f6-ee81-414c-a2c3-671549dd62b8", + "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "status": "paused", + "trigger": "manual", + "level": "info", + "startedAt": "2026-05-15T22:25:57.178Z", + "endedAt": "2026-05-15T22:25:57.215Z", + "totalDurationMs": 1, + "paused": { + "pausedAt": "2026-05-15T22:25:57.216Z", + "resumeAt": "2026-05-16T18:25:57.200Z", + "pauseKind": "time", + "blockedOnBlockId": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf", + "pausedExecutionId": "438bf05b-bd3c-4011-b78e-b19c112eeb66", + "pausePointCount": 1, + "resumedCount": 0 + }, + "cost": { + "total": 0.005 + }, + "error": null, + "finalOutput": null, + "blockOutputs": null + } + }, + "failed": { + "summary": "Failed run", + "value": { + "executionId": "3ccfdeed-a63c-4e86-98e2-8bec723bca52", + "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "status": "failed", + "trigger": "api", + "level": "error", + "startedAt": "2026-05-15T22:24:50.991Z", + "endedAt": "2026-05-15T22:24:50.999Z", + "totalDurationMs": 2, + "paused": null, + "cost": { + "total": 0.005 + }, + "error": "Wait 1: Wait time exceeds maximum of 5 minutes; enable async mode to wait up to 30 days", + "finalOutput": null, + "blockOutputs": null + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/workflows/{id}/executions/{executionId}/cancel": { + "post": { + "operationId": "cancelExecution", + "summary": "Cancel Execution", + "description": "Cancel a running workflow execution. Only effective for executions that are still in progress.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/workflows/{id}/executions/{executionId}/cancel\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The unique identifier of the execution to cancel.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + } + ], + "responses": { + "200": { + "description": "Execution was successfully cancelled.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the cancellation was successful." + }, + "executionId": { + "type": "string", + "description": "The ID of the cancelled execution." + } + } + }, + "example": { + "success": true, + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/jobs/{jobId}": { + "get": { + "operationId": "getJobStatus", + "summary": "Get Job Status", + "description": "Poll the status of an asynchronous workflow execution. Use the jobId returned from the Execute Workflow endpoint when the execution is queued asynchronously.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/jobs/{jobId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "description": "The job identifier returned in the async execution response.", + "schema": { + "type": "string", + "example": "job_4a3b2c1d0e" + } + } + ], + "responses": { + "200": { + "description": "Current status of the job. When completed, includes the execution output.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobStatus" + }, + "example": { + "success": true, + "taskId": "job_abc123", + "status": "completed", + "output": { + "content": "Done" + }, + "metadata": { + "startTime": "2026-01-15T10:30:00Z" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/workflows/{id}/paused": { + "get": { + "operationId": "listPausedExecutions", + "summary": "List Paused Executions", + "description": "List all paused executions for a workflow. Workflows pause at Human in the Loop blocks and wait for input before continuing. Use this endpoint to discover which executions need attention.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/workflows/{id}/paused?status=paused\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "description": "Filter paused executions by status.", + "schema": { + "type": "string", + "example": "paused" + } + } + ], + "responses": { + "200": { + "description": "List of paused executions.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pausedExecutions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PausedExecutionSummary" + } + } + } + }, + "example": { + "pausedExecutions": [ + { + "id": "pe_abc123", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "status": "paused", + "totalPauseCount": 1, + "resumedCount": 0, + "pausedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "expiresAt": null, + "metadata": null, + "triggerIds": [], + "pausePoints": [ + { + "contextId": "ctx_xyz789", + "blockId": "block_hitl_1", + "registeredAt": "2026-01-15T10:30:00Z", + "resumeStatus": "paused", + "snapshotReady": true, + "resumeLinks": { + "apiUrl": "https://www.sim.ai/api/resume/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13/ctx_xyz789", + "uiUrl": "https://www.sim.ai/resume/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "contextId": "ctx_xyz789", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "response": { + "displayData": { + "title": "Approval Required", + "message": "Please review this request" + }, + "formFields": [] + } + } + ] + } + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/workflows/{id}/paused/{executionId}": { + "get": { + "operationId": "getPausedExecution", + "summary": "Get Paused Execution", + "description": "Get detailed information about a specific paused execution, including its pause points, execution snapshot, and resume queue. Use this to inspect the state before resuming.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/workflows/{id}/paused/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + } + ], + "responses": { + "200": { + "description": "Paused execution details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PausedExecutionDetail" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/resume/{workflowId}/{executionId}": { + "get": { + "operationId": "getPausedExecutionByResumePath", + "summary": "Get Paused Execution (Resume Path)", + "description": "Get detailed information about a specific paused execution using the resume URL path. Returns the same data as the workflow paused execution detail endpoint.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + } + ], + "responses": { + "200": { + "description": "Paused execution details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PausedExecutionDetail" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + } + } + } + }, + "/api/resume/{workflowId}/{executionId}/{contextId}": { + "get": { + "operationId": "getPauseContext", + "summary": "Get Pause Context", + "description": "Get detailed information about a specific pause context within a paused execution. Returns the pause point details, resume queue state, and any active resume entry.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}/{contextId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + }, + { + "name": "contextId", + "in": "path", + "required": true, + "description": "The pause context ID to retrieve details for.", + "schema": { + "type": "string", + "example": "ctx_xyz789" + } + } + ], + "responses": { + "200": { + "description": "Pause context details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PauseContextDetail" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "post": { + "operationId": "resumeExecution", + "summary": "Resume Execution", + "description": "Resume a paused workflow execution by providing input for a specific pause context. The execution continues from where it paused, using the provided input. Supports synchronous, asynchronous, and streaming modes (determined by the original execution's configuration).", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}/{contextId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"input\": {\n \"approved\": true,\n \"comment\": \"Looks good to me\"\n }\n }'" + } + ], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + }, + { + "name": "contextId", + "in": "path", + "required": true, + "description": "The pause context ID to resume. Found in the pause point's contextId field or resumeLinks.", + "schema": { + "type": "string", + "example": "ctx_xyz789" + } + } + ], + "requestBody": { + "description": "Input data for the resumed execution. The structure depends on the workflow's Human in the Loop block configuration.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "input": { + "type": "object", + "description": "Key-value pairs to pass as input to the resumed execution. If omitted, the entire request body is used as input.", + "additionalProperties": true + } + } + }, + "example": { + "input": { + "approved": true, + "comment": "Looks good to me" + } + } + } + } + }, + "responses": { + "200": { + "description": "Resume execution completed synchronously, or resume was queued behind another in-progress resume.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResumeResult" + }, + { + "type": "object", + "description": "Resume has been queued behind another in-progress resume.", + "properties": { + "status": { + "type": "string", + "enum": ["queued"], + "description": "Indicates the resume is queued." + }, + "executionId": { + "type": "string", + "description": "The execution ID assigned to this resume." + }, + "queuePosition": { + "type": "integer", + "description": "Position in the resume queue." + }, + "message": { + "type": "string", + "description": "Human-readable status message." + } + } + }, + { + "type": "object", + "description": "Resume execution started (non-API-key callers). The execution runs asynchronously.", + "properties": { + "status": { + "type": "string", + "enum": ["started"], + "description": "Indicates the resume execution has started." + }, + "executionId": { + "type": "string", + "description": "The execution ID for the resumed workflow." + }, + "message": { + "type": "string", + "description": "Human-readable status message." + } + } + } + ] + }, + "examples": { + "sync": { + "summary": "Synchronous completion", + "value": { + "success": true, + "status": "completed", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "output": { + "result": "Approved and processed" + }, + "error": null, + "metadata": { + "duration": 850, + "startTime": "2026-01-15T10:35:00Z", + "endTime": "2026-01-15T10:35:01Z" + } + } + }, + "queued": { + "summary": "Queued behind another resume", + "value": { + "status": "queued", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "queuePosition": 2, + "message": "Resume queued. It will run after current resumes finish." + } + }, + "started": { + "summary": "Execution started (fire and forget)", + "value": { + "status": "started", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "message": "Resume execution started." + } + } + } + } + } + }, + "202": { + "description": "Resume execution has been queued for asynchronous processing. Poll the statusUrl for results.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AsyncExecutionResult" + }, + "example": { + "success": true, + "async": true, + "jobId": "job_4a3b2c1d0e", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "message": "Resume execution queued", + "statusUrl": "https://www.sim.ai/api/jobs/job_4a3b2c1d0e" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "503": { + "description": "Failed to queue the resume execution. Retry the request.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error message." + } + } + } + } + } + } + } + } + }, + "/api/users/me/usage-limits": { + "get": { + "operationId": "getUsageLimits", + "summary": "Get Usage Limits", + "description": "Retrieve your current usage spending and storage consumption for the billing period.", + "tags": ["Usage"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/users/me/usage-limits\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "responses": { + "200": { + "description": "Current usage and storage information.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsageLimits" + }, + "example": { + "success": true, + "usage": { + "currentPeriodCost": 12.5, + "limit": 100, + "plan": "pro" + }, + "storage": { + "usedBytes": 5242880, + "limitBytes": 1073741824, + "percentUsed": 0.49 + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "parameters": [] + } + }, + "/api/v2/billing/usage": { + "get": { + "operationId": "getUsageSummary", + "summary": "Get Usage Summary", + "description": "Current-billing-period usage with the per-source credit breakdown (`workflow`, `copilot`, `knowledge-base`, …) — monitor one source's consumption directly instead of estimating it by subtraction. All values are credits (1,000 credits = $5); dollar costs are not part of this surface.", + "tags": ["Usage"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Restrict to one workspace. A workspace-scoped API key is always pinned to its own workspace; passing a different id returns 403." + } + ], + "responses": { + "200": { + "description": "The current billing period's usage summary.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": [ + "period", + "totalCredits", + "bySourceCredits", + "limitCredits", + "plan" + ], + "properties": { + "period": { + "type": "object", + "required": ["start", "end"], + "properties": { + "start": { + "type": "string", + "format": "date-time" + }, + "end": { + "type": "string", + "format": "date-time" + } + } + }, + "totalCredits": { + "type": "number" + }, + "bySourceCredits": { + "type": "object", + "additionalProperties": { + "type": "number" + }, + "description": "Credits consumed per usage source over the billing period." + }, + "limitCredits": { + "type": "number" + }, + "plan": { + "type": "string" + } + } + } + } + }, + "example": { + "data": { + "period": { + "start": "2026-07-01T00:00:00.000Z", + "end": "2026-08-01T00:00:00.000Z" + }, + "totalCredits": 512, + "bySourceCredits": { + "workflow": 380, + "copilot": 120, + "knowledge-base": 12 + }, + "limitCredits": 20000, + "plan": "pro" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + } + } + } + }, + "/api/v2/billing/usage/logs": { + "get": { + "operationId": "listUsageLogs", + "summary": "List Usage Logs", + "description": "Cursor-paged, credit-denominated ledger of the account's usage events. The per-source aggregate lives on `GET /api/v2/billing/usage`; this is the row-level detail. Page by passing `nextCursor` back as `cursor` and stop when it is null.", + "tags": ["Usage"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "source", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Restrict to one usage source (e.g. `workflow`, `copilot`)." + }, + { + "name": "workspaceId", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Restrict to one workspace. A workspace-scoped API key is always pinned to its own workspace; passing a different id returns 403." + }, + { + "name": "period", + "in": "query", + "required": false, + "schema": { + "enum": ["1d", "7d", "30d", "custom", "all"], + "default": "30d" + }, + "description": "Relative window, `all`, or `custom` (requires `startDate`)." + }, + { + "name": "startDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Start of a `custom` window. Any `Date`-parseable string." + }, + { + "name": "endDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "End of a `custom` window; defaults to now." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Opaque cursor from the previous page." + } + ], + "responses": { + "200": { + "description": "A page of usage events.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "createdAt", "source", "workflowName", "creditCost"], + "properties": { + "id": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "source": { + "type": "string" + }, + "workflowName": { + "type": ["string", "null"], + "description": "Populated only when `source` is `workflow`." + }, + "creditCost": { + "type": "number", + "description": "Apportioned so page rows sum exactly to the rounded page total; can be 0 for a sub-credit event." + } + } + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null on the final page." + } + } + }, + "example": { + "data": [ + { + "id": "log_1", + "createdAt": "2026-07-29T18:04:11.000Z", + "source": "copilot", + "workflowName": null, + "creditCost": 12 + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "TableId": { + "name": "tableId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "description": "The unique identifier of the table." + }, + "RowId": { + "name": "rowId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "description": "The unique identifier of the row." + }, + "WorkspaceId": { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "The unique identifier of the workspace." + } + }, + "schemas": { + "ExecutionResult": { + "type": "object", + "description": "Result of a synchronous workflow execution.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the workflow executed successfully without errors.", + "example": true + }, + "executionId": { + "type": "string", + "description": "Unique identifier for this execution. Use this to query logs or cancel the execution.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "output": { + "type": "object", + "description": "Workflow output keyed by block name and output field. Structure depends on the workflow's block configuration.", + "additionalProperties": true, + "example": { + "result": "Hello, world!" + } + }, + "error": { + "type": "string", + "nullable": true, + "description": "Error message if the execution failed. null on success.", + "example": null + }, + "metadata": { + "type": "object", + "description": "Execution timing metadata.", + "properties": { + "duration": { + "type": "integer", + "description": "Total execution duration in milliseconds.", + "example": 1250 + }, + "startTime": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2025-06-20T14:15:22Z" + }, + "endTime": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed.", + "example": "2025-06-20T14:15:23Z" + } + } + } + } + }, + "AsyncExecutionResult": { + "type": "object", + "description": "Response returned when a workflow execution is queued for asynchronous processing.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the execution was successfully queued.", + "example": true + }, + "async": { + "type": "boolean", + "description": "Always true for async executions. Use this to distinguish from synchronous responses.", + "example": true + }, + "jobId": { + "type": "string", + "description": "Internal job queue identifier for tracking the execution.", + "example": "job_4a3b2c1d0e" + }, + "executionId": { + "type": "string", + "description": "Unique execution identifier. Use this to query execution status or cancel.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "message": { + "type": "string", + "description": "Human-readable status message (e.g., \"Execution queued\").", + "example": "Execution queued" + }, + "statusUrl": { + "type": "string", + "format": "uri", + "description": "URL to poll for execution status and results. Returns the full execution result once complete.", + "example": "https://www.sim.ai/api/jobs/job_4a3b2c1d0e" + } + } + }, + "JobStatus": { + "type": "object", + "description": "Status of an asynchronous job.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the request was successful.", + "example": true + }, + "taskId": { + "type": "string", + "description": "The unique identifier of the job.", + "example": "job_4a3b2c1d0e" + }, + "status": { + "type": "string", + "enum": ["queued", "processing", "completed", "failed"], + "description": "Current status of the job.", + "example": "completed" + }, + "metadata": { + "type": "object", + "description": "Timing metadata for the job.", + "properties": { + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the job started processing.", + "example": "2025-06-20T14:15:22Z" + }, + "completedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the job completed. Present only when status is completed or failed.", + "example": "2025-06-20T14:15:23Z" + }, + "duration": { + "type": "integer", + "description": "Duration of the job in milliseconds. Present only when status is completed or failed.", + "example": 1250 + } + } + }, + "output": { + "description": "The workflow execution output. Present only when status is completed.", + "type": "object", + "example": { + "result": "Hello, world!" + } + }, + "error": { + "description": "Error details. Present only when status is failed.", + "type": "string", + "example": null + }, + "estimatedDuration": { + "type": "integer", + "description": "Estimated duration in milliseconds. Present only when status is queued or processing.", + "example": 2000 + } + } + }, + "WorkflowExecutionStatus": { + "type": "object", + "description": "Current status of a workflow execution.", + "properties": { + "executionId": { + "type": "string", + "description": "The unique identifier of the execution.", + "example": "9254f1c9-5a11-4a12-91e3-8065293f3609" + }, + "workflowId": { + "type": "string", + "description": "The unique identifier of the workflow.", + "example": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7" + }, + "status": { + "type": "string", + "enum": ["pending", "running", "paused", "completed", "failed", "cancelled"], + "description": "Current normalized lifecycle status. `paused` is set when a row exists in pausedExecutions with status `paused` or `partially_resumed`; otherwise the workflowExecutionLogs row's status field is used.", + "example": "completed" + }, + "trigger": { + "type": "string", + "enum": ["api", "manual", "schedule", "webhook", "chat"], + "description": "What triggered the execution.", + "example": "api" + }, + "level": { + "type": "string", + "enum": ["info", "warning", "error"], + "description": "Log level of the execution.", + "example": "info" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2026-05-15T19:43:12.189Z" + }, + "endedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp when execution ended. Null while the run is in flight.", + "example": "2026-05-15T19:45:45.224Z" + }, + "totalDurationMs": { + "type": "integer", + "nullable": true, + "description": "Total duration of the execution in milliseconds. Null while the run is in flight.", + "example": 153035 + }, + "paused": { + "type": "object", + "nullable": true, + "description": "Pause-state details. Present only when status is `paused`.", + "properties": { + "pausedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was paused.", + "example": "2026-05-15T22:25:57.216Z" + }, + "resumeAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Earliest scheduled resume time across active pause points. Null for human-only pauses.", + "example": "2026-05-16T18:25:57.200Z" + }, + "pauseKind": { + "type": "string", + "enum": ["time", "human"], + "nullable": true, + "description": "What kind of pause the workflow is waiting on.", + "example": "time" + }, + "blockedOnBlockId": { + "type": "string", + "nullable": true, + "description": "The block currently blocking resume.", + "example": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf" + }, + "pausedExecutionId": { + "type": "string", + "description": "ID of the paused-execution row, useful for cross-referencing with the human-in-the-loop endpoints.", + "example": "438bf05b-bd3c-4011-b78e-b19c112eeb66" + }, + "pausePointCount": { + "type": "integer", + "description": "Total number of pause points recorded for this execution.", + "example": 1 + }, + "resumedCount": { + "type": "integer", + "description": "Number of pause points already resumed.", + "example": 0 + } + } + }, + "cost": { + "type": "object", + "nullable": true, + "description": "Cost summary. Detailed token / model breakdown lives on the /v1/logs detail endpoint.", + "properties": { + "total": { + "type": "number", + "description": "Total cost in USD.", + "example": 0.005 + } + } + }, + "error": { + "type": "string", + "nullable": true, + "description": "Error message. Present only when status is `failed`.", + "example": null + }, + "finalOutput": { + "type": "object", + "nullable": true, + "description": "The workflow's final output. Returned only when ?includeOutput=true AND status is `completed`.", + "example": null + }, + "blockOutputs": { + "type": "object", + "nullable": true, + "description": "Per-block outputs keyed by the selector string. Returned only when `?selectedOutputs` is set.", + "additionalProperties": true, + "example": { + "c1b90bce-8a82-42a5-b6a5-5762846c2eaf.waitDuration": 60000, + "c1b90bce-8a82-42a5-b6a5-5762846c2eaf.status": "completed" + } + } + } + }, + "UsageLimits": { + "type": "object", + "description": "Current usage and storage information for the authenticated user.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the request was successful." + }, + "usage": { + "type": "object", + "description": "Current billing period usage.", + "properties": { + "currentPeriodCost": { + "type": "number", + "description": "Total spend in the current billing period in USD." + }, + "limit": { + "type": "number", + "description": "Maximum allowed spend for the current billing period in USD." + }, + "plan": { + "type": "string", + "description": "Your current subscription plan (e.g., free, pro, team)." + } + } + }, + "storage": { + "type": "object", + "description": "File storage usage.", + "properties": { + "usedBytes": { + "type": "integer", + "description": "Total storage used in bytes." + }, + "limitBytes": { + "type": "integer", + "description": "Maximum storage allowed in bytes." + }, + "percentUsed": { + "type": "number", + "description": "Percentage of storage used (0-100)." + } + } + } + } + }, + "PausedExecutionSummary": { + "type": "object", + "description": "Summary of a paused workflow execution.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the paused execution record." + }, + "workflowId": { + "type": "string", + "description": "The workflow this execution belongs to." + }, + "executionId": { + "type": "string", + "description": "The execution that was paused." + }, + "status": { + "type": "string", + "description": "Current status of the paused execution.", + "example": "paused" + }, + "totalPauseCount": { + "type": "integer", + "description": "Total number of pause points in this execution." + }, + "resumedCount": { + "type": "integer", + "description": "Number of pause points that have been resumed." + }, + "pausedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the execution was paused." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the paused execution record was last updated." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the paused execution will expire and be cleaned up." + }, + "metadata": { + "type": "object", + "nullable": true, + "description": "Additional metadata associated with the paused execution.", + "additionalProperties": true + }, + "triggerIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of triggers that initiated the original execution." + }, + "pausePoints": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PausePoint" + }, + "description": "List of pause points in the execution." + } + } + }, + "PausePoint": { + "type": "object", + "description": "A point in the workflow where execution has been paused awaiting human input.", + "properties": { + "contextId": { + "type": "string", + "description": "Unique identifier for this pause context. Used when resuming execution." + }, + "blockId": { + "type": "string", + "description": "The block ID where execution paused." + }, + "response": { + "description": "Data returned by the block before pausing, including display data and form fields." + }, + "registeredAt": { + "type": "string", + "format": "date-time", + "description": "When this pause point was registered." + }, + "resumeStatus": { + "type": "string", + "enum": ["paused", "resumed", "failed", "queued", "resuming"], + "description": "Current status of this pause point." + }, + "snapshotReady": { + "type": "boolean", + "description": "Whether the execution snapshot is ready for resumption." + }, + "resumeLinks": { + "type": "object", + "description": "Links for resuming this pause point.", + "properties": { + "apiUrl": { + "type": "string", + "format": "uri", + "description": "API endpoint URL to POST resume input to." + }, + "uiUrl": { + "type": "string", + "format": "uri", + "description": "UI URL for a human to review and approve." + }, + "contextId": { + "type": "string", + "description": "The context ID for this pause point." + }, + "executionId": { + "type": "string", + "description": "The execution ID." + }, + "workflowId": { + "type": "string", + "description": "The workflow ID." + } + } + }, + "queuePosition": { + "type": "integer", + "nullable": true, + "description": "Position in the resume queue, if queued." + }, + "latestResumeEntry": { + "$ref": "#/components/schemas/ResumeQueueEntry", + "nullable": true, + "description": "The most recent resume queue entry for this pause point." + }, + "parallelScope": { + "type": "object", + "description": "Scope information when the pause occurs inside a parallel branch.", + "properties": { + "parallelId": { + "type": "string", + "description": "Identifier of the parallel execution group." + }, + "branchIndex": { + "type": "integer", + "description": "Index of the branch within the parallel group." + }, + "branchTotal": { + "type": "integer", + "description": "Total number of branches in the parallel group." + } + } + }, + "loopScope": { + "type": "object", + "description": "Scope information when the pause occurs inside a loop.", + "properties": { + "loopId": { + "type": "string", + "description": "Identifier of the loop." + }, + "iteration": { + "type": "integer", + "description": "Current loop iteration number." + } + } + } + } + }, + "ResumeQueueEntry": { + "type": "object", + "description": "An entry in the resume execution queue.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for this queue entry." + }, + "pausedExecutionId": { + "type": "string", + "description": "The paused execution this entry belongs to." + }, + "parentExecutionId": { + "type": "string", + "description": "The original execution that was paused." + }, + "newExecutionId": { + "type": "string", + "description": "The new execution ID created for the resume." + }, + "contextId": { + "type": "string", + "description": "The pause context ID being resumed." + }, + "resumeInput": { + "description": "The input provided when resuming." + }, + "status": { + "type": "string", + "description": "Status of this queue entry (e.g., pending, claimed, completed, failed)." + }, + "queuedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the entry was added to the queue." + }, + "claimedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When execution started processing this entry." + }, + "completedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When execution completed." + }, + "failureReason": { + "type": "string", + "nullable": true, + "description": "Reason for failure, if the resume failed." + } + } + }, + "PausedExecutionDetail": { + "type": "object", + "description": "Detailed information about a paused execution, including the execution snapshot and resume queue.", + "allOf": [ + { + "$ref": "#/components/schemas/PausedExecutionSummary" + }, + { + "type": "object", + "properties": { + "executionSnapshot": { + "type": "object", + "description": "Serialized execution state for resumption.", + "properties": { + "snapshot": { + "type": "string", + "description": "Serialized execution snapshot data." + }, + "triggerIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Trigger IDs from the snapshot." + } + } + }, + "queue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResumeQueueEntry" + }, + "description": "Resume queue entries for this execution." + } + } + } + ] + }, + "PauseContextDetail": { + "type": "object", + "description": "Detailed information about a specific pause context within a paused execution.", + "properties": { + "execution": { + "$ref": "#/components/schemas/PausedExecutionSummary", + "description": "Summary of the parent paused execution." + }, + "pausePoint": { + "$ref": "#/components/schemas/PausePoint", + "description": "The specific pause point for this context." + }, + "queue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResumeQueueEntry" + }, + "description": "Resume queue entries for this context." + }, + "activeResumeEntry": { + "$ref": "#/components/schemas/ResumeQueueEntry", + "nullable": true, + "description": "The currently active resume entry, if any." + } + } + }, + "ResumeResult": { + "type": "object", + "description": "Result of a synchronous resume execution.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the resume execution completed successfully." + }, + "status": { + "type": "string", + "description": "Execution status.", + "enum": ["completed", "failed", "paused", "cancelled"], + "example": "completed" + }, + "executionId": { + "type": "string", + "description": "The new execution ID for the resumed workflow." + }, + "output": { + "type": "object", + "description": "Workflow output from the resumed execution.", + "additionalProperties": true + }, + "error": { + "type": "string", + "nullable": true, + "description": "Error message if the execution failed." + }, + "metadata": { + "type": "object", + "description": "Execution timing metadata.", + "properties": { + "duration": { + "type": "integer", + "description": "Total execution duration in milliseconds." + }, + "startTime": { + "type": "string", + "format": "date-time", + "description": "When the resume execution started." + }, + "endTime": { + "type": "string", + "format": "date-time", + "description": "When the resume execution completed." + } + } + } + } + }, + "V2Error": { + "type": "object", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Stable machine-readable code, e.g. `BAD_REQUEST`, `FORBIDDEN`, `RATE_LIMITED`." + }, + "message": { + "type": "string" + }, + "details": { + "description": "Optional structured context (e.g. per-field validation issues)." + } + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Invalid request parameters. Check the details array for specific validation errors.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message describing the validation failure." + }, + "details": { + "type": "array", + "description": "List of specific validation errors with field-level details.", + "items": { + "type": "object" + } + } + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "Forbidden": { + "description": "Access denied. You do not have permission to access this resource. For audit log endpoints, this requires an Enterprise subscription and organization admin/owner role.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found. Verify the ID is correct and belongs to your workspace.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration specified in the Retry-After header before retrying.", + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message with rate limit details." + } + } + } + } + } + }, + "RowsUpdated": { + "description": "Rows updated.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Indicates whether the request was successful." + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation message describing how many rows were updated." + }, + "updatedCount": { + "type": "integer", + "description": "Number of rows that were updated." + }, + "updatedRowIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of IDs for each row that was updated." + } + }, + "description": "Response payload." + } + } + }, + "example": { + "success": true, + "data": { + "message": "Rows updated successfully", + "updatedCount": 2, + "updatedRowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ] + } + } + } + } + }, + "V2BadRequest": { + "description": "Invalid request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2Unauthorized": { + "description": "Missing or invalid API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2Forbidden": { + "description": "The credential is not authorized for the requested resource.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2RateLimited": { + "description": "Rate limit exceeded; retry after the window resets.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json new file mode 100644 index 00000000000..476d289159e --- /dev/null +++ b/apps/docs/openapi-v2-files-audit.json @@ -0,0 +1,2220 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 — Files & Audit Logs", + "description": "Version 2 of the Sim REST API for the Files and Audit Logs surfaces.\n\n## Conventions (v2)\n\nEvery v2 endpoint shares one response family:\n\n- **Single resource:** `{ \"data\": T }`\n- **List:** `{ \"data\": T[], \"nextCursor\": string | null }`\n- **Error:** `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`\n\n### Cursor pagination\n\nLists use an opaque keyset cursor (Stripe/Slack-style): pass `limit` and `cursor` in, receive `data` and `nextCursor` out. Treat `cursor` as opaque — pass back the `nextCursor` from the previous page verbatim. When `nextCursor` is `null` there are no more results. Total counts are not returned on lists.\n\n### Rate limiting\n\nRate-limit state is carried in response headers, not the body: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (an ISO 8601 timestamp). A throttled request returns `429` with a `Retry-After` header (seconds).\n\n### Authentication\n\nAll endpoints authenticate with the `X-API-Key` header (a personal or workspace API key). Files endpoints are workspace-scoped via the required `workspaceId` query parameter. Audit Logs endpoints are organization-scoped enterprise endpoints and require an Enterprise subscription plus an organization admin or owner role.", + "version": "2.0.0", + "contact": { + "name": "Sim Support", + "email": "help@sim.ai", + "url": "https://www.sim.ai" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Files", + "description": "Upload, download, list, rename, 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", + "description": "Query the organization audit trail (v2). Organization-scoped enterprise endpoints requiring an Enterprise subscription and an organization admin or owner role." + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/v2/files": { + "get": { + "operationId": "listFiles", + "summary": "List Files", + "description": "List a workspace's files with opaque cursor pagination. 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": [ + { + "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": "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", + "required": false, + "description": "Maximum number of files to return per page. Clamped to the range 1–1000. Defaults to 100.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 100 + } + }, + { + "$ref": "#/components/parameters/Cursor" + }, + { + "name": "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": { + "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", + "folderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", + "folderPath": "Reports/Q1", + "uploadedBy": "user_abc123", + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "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 — 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": [ + { + "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" + }, + { + "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": { + "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", + "folderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", + "folderPath": "Reports/Q1", + "uploadedBy": "user_abc123", + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "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 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": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "A file named \"data.csv\" already exists in this 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" + } + } + }, + "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": { + "get": { + "operationId": "listAuditLogs", + "summary": "List Audit Logs", + "description": "List audit log entries for the authenticated user's organization with opaque cursor pagination. These are organization-scoped (not workspace-scoped) enterprise endpoints: the caller must belong to an organization with an active Enterprise subscription and hold an admin or owner role — otherwise the request returns `403`. The `ipAddress` and `userAgent` fields are intentionally excluded from entries for privacy.", + "tags": ["Audit Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/audit-logs?limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "action", + "in": "query", + "required": false, + "description": "Filter by action type (e.g., file.uploaded, workflow.deployed, member.invited).", + "schema": { + "type": "string" + } + }, + { + "name": "resourceType", + "in": "query", + "required": false, + "description": "Filter by resource type (e.g., file, workflow, workspace, member).", + "schema": { + "type": "string" + } + }, + { + "name": "resourceId", + "in": "query", + "required": false, + "description": "Filter by a specific resource ID.", + "schema": { + "type": "string" + } + }, + { + "name": "workspaceId", + "in": "query", + "required": false, + "description": "Filter by a workspace within your organization. Must belong to your organization, otherwise the request returns 400.", + "schema": { + "type": "string" + } + }, + { + "name": "actorId", + "in": "query", + "required": false, + "description": "Filter by the user who performed the action. Must be a member of your organization, otherwise the request returns 400.", + "schema": { + "type": "string" + } + }, + { + "name": "startDate", + "in": "query", + "required": false, + "description": "Only return entries at or after this ISO 8601 timestamp.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "endDate", + "in": "query", + "required": false, + "description": "Only return entries at or before this ISO 8601 timestamp.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "includeDeparted", + "in": "query", + "required": false, + "description": "When true, include entries from users who have left the organization. Defaults to false.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of entries to return per page. Must be between 1 and 100. Defaults to 50.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "$ref": "#/components/parameters/Cursor" + } + ], + "responses": { + "200": { + "description": "A page of audit log entries.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2AuditLogListResponse" + }, + "example": { + "data": [ + { + "id": "audit_2c3d4e5f6g", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "actorId": "user_abc123", + "actorName": "Jane Smith", + "actorEmail": "jane@example.com", + "action": "file.uploaded", + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "resourceName": "data.csv", + "description": "Uploaded file \"data.csv\" via API", + "metadata": { + "fileSize": 1024, + "fileType": "text/csv" + }, + "createdAt": "2026-01-15T10:30:00Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "description": "The request was malformed: an invalid query parameter, an `actorId` that is not a member of your organization, or a `workspaceId` that does not belong to your organization.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "actorId is not a member of your organization" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/audit-logs/{id}": { + "get": { + "operationId": "getAuditLog", + "summary": "Get Audit Log", + "description": "Retrieve a single audit log entry by ID, scoped to the authenticated user's organization. Organization-scoped (not workspace-scoped): the caller must belong to an organization with an active Enterprise subscription and hold an admin or owner role — otherwise the request returns `403`. An entry outside your organization returns `404` (existence is not leaked). The `ipAddress` and `userAgent` fields are intentionally excluded for privacy.", + "tags": ["Audit Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/audit-logs/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique audit log entry identifier.", + "schema": { + "type": "string", + "minLength": 1, + "example": "audit_2c3d4e5f6g" + } + } + ], + "responses": { + "200": { + "description": "The audit log entry.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2AuditLogResponse" + }, + "example": { + "data": { + "id": "audit_2c3d4e5f6g", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "actorId": "user_abc123", + "actorName": "Jane Smith", + "actorEmail": "jane@example.com", + "action": "file.uploaded", + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "resourceName": "data.csv", + "description": "Uploaded file \"data.csv\" via API", + "metadata": { + "fileSize": 1024, + "fileType": "text/csv" + }, + "createdAt": "2026-01-15T10:30:00Z" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/{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 + } + } + } + } + }, + "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/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": { + "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." + } + } + } + } + }, + "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": { + "BadRequest": { + "description": "Invalid request. Inspect `error.message` and the optional `error.details` for specifics.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { + "path": ["workspaceId"], + "code": "invalid_type", + "message": "Required" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + } + } + } + } + }, + "Forbidden": { + "description": "Access denied. For Files, the API key lacks access to the workspace. For Audit Logs, this requires an Enterprise subscription and an organization admin or owner role.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Active enterprise subscription required" + } + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found, or it does not belong to the authorized scope.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "File not found" + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration in the Retry-After header before retrying.", + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer", + "example": 30 + } + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-15T11:00:00Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected error occurred on the server.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + }, + "Conflict": { + "description": "The request conflicts with existing state — most often a name already taken in the destination folder.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "A file named \"data.csv\" already exists in this workspace" + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The body exceeds the per-request size limit, or accepting it would push the workspace past its storage quota.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Storage limit exceeded" + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json new file mode 100644 index 00000000000..38a6d2ed2f5 --- /dev/null +++ b/apps/docs/openapi-v2-knowledge.json @@ -0,0 +1,1906 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 \u2014 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** \u2014 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 \u2014 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` \u21c4 `NOT_FOUND`).\n- **Rate limiting** \u2014 Every response carries the current limiter state in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. A throttled request returns `429` with a `Retry-After` header.", + "version": "2.0.0", + "contact": { + "name": "Sim Support", + "email": "help@sim.ai", + "url": "https://www.sim.ai" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Knowledge Bases", + "description": "Create and manage knowledge bases, upload and inspect documents, and run vector and tag search (v2 API)." + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/v2/knowledge": { + "get": { + "operationId": "listKnowledgeBases", + "summary": "List Knowledge Bases", + "description": "List all knowledge bases in a workspace. The full bounded per-workspace set is returned as a single page, so `nextCursor` is always `null` today; treat the response as a standard cursor list so pagination can be added later without a contract change.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "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": { + "200": { + "description": "Knowledge bases for the workspace.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The knowledge bases in the workspace.", + "items": { + "$ref": "#/components/schemas/KnowledgeBase" + } + }, + "nextCursor": { + "$ref": "#/components/schemas/NextCursor" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createKnowledgeBase", + "summary": "Create Knowledge Base", + "description": "Create a new knowledge base in a workspace. The embedding model and dimension are fixed server-side and cannot be supplied. Returns `201` with the created knowledge base.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/knowledge\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"Product Documentation\",\n \"description\": \"All product docs and guides\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The knowledge base to create.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateKnowledgeBaseBody" + } + } + } + }, + "responses": { + "201": { + "description": "The knowledge base was created.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeBaseEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/{id}": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + } + ], + "get": { + "operationId": "getKnowledgeBase", + "summary": "Get Knowledge Base", + "description": "Retrieve a single knowledge base by ID. A knowledge base that does not exist, belongs to another workspace, or that the caller cannot read is reported as `404` so cross-workspace existence is never leaked.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge/{id}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The knowledge base.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeBaseEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "operationId": "updateKnowledgeBase", + "summary": "Update Knowledge Base", + "description": "Update a knowledge base's name, description, or chunking config. At least one of `name`, `description`, or `chunkingConfig` must be provided. The target workspace is carried in the request body.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X PUT \\\n \"https://www.sim.ai/api/v2/knowledge/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"Updated name\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The fields to update. At least one of name, description, or chunkingConfig is required.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateKnowledgeBaseBody" + } + } + } + }, + "responses": { + "200": { + "description": "The updated knowledge base.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeBaseEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteKnowledgeBase", + "summary": "Delete Knowledge Base", + "description": "Delete a knowledge base and all of its documents. Returns a delete acknowledgement with the id of the removed knowledge base.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/knowledge/{id}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The knowledge base was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/search": { + "post": { + "operationId": "searchKnowledge", + "summary": "Search Knowledge", + "description": "Run vector and/or tag search across one or more knowledge bases. Provide a `query` for semantic vector search, `tagFilters` for structured filtering, or both. At least one of `query` or `tagFilters` is required.\n\nNotes and limits:\n- Tag filters are only supported when searching a single knowledge base.\n- When a `query` is supplied, all targeted knowledge bases must use the same embedding model; otherwise the request is rejected. Search such knowledge bases separately.\n- A text query consumes hosted embedding (and optional rerank) usage; tag-only search is free.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/knowledge/search\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"knowledgeBaseIds\": [\"KB_ID\"],\n \"query\": \"How do I reset my password?\",\n \"topK\": 10\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The search request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchBody" + } + } + } + }, + "responses": { + "200": { + "description": "Search results.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchEnvelope" + } + } + } + }, + "400": { + "description": "Invalid request. Returned when neither `query` nor `tagFilters` is provided, when tag filters target more than one knowledge base, when the selected knowledge bases use different embedding models, or when a tag name/value is invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "crossModel": { + "summary": "Knowledge bases use different embedding models", + "value": { + "error": { + "code": "BAD_REQUEST", + "message": "Selected knowledge bases use different embedding models and cannot be searched together. Search them separately." + } + } + }, + "multiKbTagFilter": { + "summary": "Tag filters across multiple knowledge bases", + "value": { + "error": { + "code": "BAD_REQUEST", + "message": "Tag filters are only supported when searching a single knowledge base" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "description": "One or more of the requested knowledge bases do not exist or are not accessible from this workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Knowledge base not found or access denied" + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/{id}/documents": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "get": { + "operationId": "listKnowledgeDocuments", + "summary": "List Documents", + "description": "List documents in a knowledge base. Supports search, enabled-state filtering, sorting, and cursor pagination. Pass the returned `nextCursor` back as `cursor` to fetch the next page; the total document count is available as `docCount` on the parent knowledge base.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents?workspaceId=YOUR_WORKSPACE_ID&limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of documents to return per page.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor from a previous response's `nextCursor`. Omit for the first page.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against document filenames.", + "schema": { + "type": "string" + } + }, + { + "name": "enabledFilter", + "in": "query", + "required": false, + "description": "Filter documents by their enabled state.", + "schema": { + "type": "string", + "enum": ["all", "enabled", "disabled"], + "default": "all" + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by.", + "schema": { + "type": "string", + "enum": [ + "filename", + "fileSize", + "tokenCount", + "chunkCount", + "uploadedAt", + "processingStatus", + "enabled" + ], + "default": "uploadedAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc" + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "Workspace that owns the knowledge base." + } + ], + "responses": { + "200": { + "description": "Documents in the knowledge base.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The documents on this page.", + "items": { + "$ref": "#/components/schemas/DocumentSummary" + } + }, + "nextCursor": { + "$ref": "#/components/schemas/NextCursor" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "uploadKnowledgeDocument", + "summary": "Upload Document", + "description": "Upload a single document to a knowledge base as `multipart/form-data`. The workspace is supplied as the `workspaceId` query parameter (not a form field) so authorization runs before the file body is buffered. The maximum file size is 100 MB. Processing is asynchronous: the document is returned with `processingStatus: \"pending\"` and indexing continues in the background \u2014 poll the Get Document endpoint to observe progress.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -F \"file=@/path/to/document.pdf\"" + } + ], + "requestBody": { + "required": true, + "description": "The file to upload.", + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": ["file"], + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "The document file to upload (max 100 MB)." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The document was accepted and queued for processing.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentSummaryEnvelope" + } + } + } + }, + "400": { + "description": "Invalid request. Returned when the body is not valid multipart form data or the required `file` field is missing.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "file form field is required" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "description": "The uploaded file exceeds the 100 MB limit, or the workspace storage limit has been reached.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "File size exceeds 100MB limit (123.45MB)" + } + } + } + } + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "Workspace that owns the knowledge base." + } + ] + } + }, + "/api/v2/knowledge/{id}/documents/{documentId}": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + }, + { + "$ref": "#/components/parameters/DocumentId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "get": { + "operationId": "getKnowledgeDocument", + "summary": "Get Document", + "description": "Retrieve the full detail for a single document, including processing state and connector provenance.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents/{documentId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "responses": { + "200": { + "description": "The document detail.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "Workspace that owns the knowledge base." + } + ] + }, + "delete": { + "operationId": "deleteKnowledgeDocument", + "summary": "Delete Document", + "description": "Delete a single document from a knowledge base. Returns a delete acknowledgement with the id of the removed document.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents/{documentId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "responses": { + "200": { + "description": "The document was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "Workspace that owns the knowledge base." + } + ] + } + } + }, + "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"] + }, + "strategyOptions": { + "type": "object", + "additionalProperties": false, + "description": "Strategy-specific tuning. `pattern`/`strictBoundaries` apply to the `regex` strategy; `separators` to `text`; `recipe` to `recursive`.", + "properties": { + "pattern": { + "type": "string", + "maxLength": 500 + }, + "separators": { + "type": "array", + "items": { + "type": "string" + } + }, + "recipe": { + "enum": ["plain", "markdown", "code"] + }, + "strictBoundaries": { + "type": "boolean" + } + } + } + } + }, + "ChunkingConfigInput": { + "type": "object", + "description": "Chunking configuration for the knowledge base. Defaults are applied when omitted.", + "properties": { + "maxSize": { + "type": "integer", + "description": "Maximum chunk size, in tokens.", + "minimum": 100, + "maximum": 4000, + "default": 1024 + }, + "minSize": { + "type": "integer", + "description": "Minimum chunk size, in characters.", + "minimum": 1, + "maximum": 2000, + "default": 100 + }, + "overlap": { + "type": "integer", + "description": "Number of overlapping characters between adjacent chunks.", + "minimum": 0, + "maximum": 500, + "default": 200 + } + } + }, + "KnowledgeBase": { + "type": "object", + "description": "A knowledge base: a collection of documents indexed for vector and tag search.", + "required": [ + "id", + "name", + "description", + "tokenCount", + "embeddingModel", + "embeddingDimension", + "chunkingConfig", + "createdAt", + "updatedAt" + ], + "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" + } + }, + "searchMode": { + "type": "string", + "enum": ["vector", "hybrid"], + "default": "vector", + "description": "Retrieval strategy. `vector` is semantic-only; `hybrid` additionally runs a full-text leg and fuses the two by reciprocal rank, which recovers exact tokens (error codes, ticket keys, identifiers) that embeddings rank poorly." + } + } + }, + "SearchResult": { + "type": "object", + "description": "A single search hit (a matching document chunk).", + "required": [ + "documentId", + "documentName", + "sourceUrl", + "content", + "chunkIndex", + "metadata", + "similarity" + ], + "properties": { + "documentId": { + "type": "string", + "description": "Identifier of the document the chunk belongs to.", + "example": "b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12" + }, + "documentName": { + "type": ["string", "null"], + "description": "Filename of the source document, or null if unavailable.", + "example": "getting-started.pdf" + }, + "sourceUrl": { + "type": ["string", "null"], + "description": "Original source URL of the document, or null for direct uploads.", + "example": null + }, + "content": { + "type": "string", + "description": "The matching chunk's text content.", + "example": "To reset your password, open Settings and choose \"Security\"." + }, + "chunkIndex": { + "type": "integer", + "description": "Zero-based index of the chunk within its document.", + "example": 3 + }, + "metadata": { + "type": "object", + "description": "The document's tag values keyed by tag display name. Values are user-defined and may be strings, numbers, booleans, or dates.", + "additionalProperties": true, + "example": { + "category": "billing", + "priority": 2 + } + }, + "similarity": { + "type": "number", + "description": "Similarity score in the range 0\u20131 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-resources.json b/apps/docs/openapi-v2-resources.json new file mode 100644 index 00000000000..f7a6117a2c9 --- /dev/null +++ b/apps/docs/openapi-v2-resources.json @@ -0,0 +1,2868 @@ +{ + "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" }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the MCP server `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", + "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "createdAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" } + } + ], + "responses": { + "200": { + "description": "MCP servers registered in the workspace.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The MCP servers registered in the workspace.", + "items": { "$ref": "#/components/schemas/McpServer" } + }, + "nextCursor": { "$ref": "#/components/schemas/NextCursor" } + } + }, + "example": { + "data": [ + { + "id": "mcp-3f7a9c21", + "name": "Docs server", + "description": "Internal documentation tools", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "timeout": 30000, + "retries": 3, + "enabled": true, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 7, + "lastToolsRefresh": "2025-06-20T14:02:11.000Z", + "lastConnected": "2025-06-20T14:02:11.000Z", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false + } + ], + "nextCursor": null + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "post": { + "operationId": "createMcpServer", + "summary": "Create MCP Server", + "description": "Register a new MCP server in a workspace. Requires `write` permission on the workspace.\n\nA server's identity is derived from its URL, so registering a URL that is already registered returns `409 CONFLICT` rather than overwriting the existing server — use `PATCH /api/v2/mcp-servers/{id}` to change one.\n\nThe `url` must be an absolute `http`/`https` URL and may not contain `{{ENV_VAR}}` references: templated hostnames defer domain-allowlist and SSRF checks to call time, which is not safe to accept over an API key.\n\n`headers` and `oauthClientSecret` are write-only and are never returned.", + "tags": ["MCP Servers"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/mcp-servers\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"Docs server\",\n \"url\": \"https://mcp.example.com/sse\",\n \"headers\": { \"Authorization\": \"Bearer YOUR_TOKEN\" }\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The MCP server to register.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateMcpServerBody" }, + "examples": { + "headerAuth": { + "summary": "Header-authenticated server", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "Docs server", + "description": "Internal documentation tools", + "url": "https://mcp.example.com/sse", + "authType": "headers", + "headers": { "Authorization": "Bearer YOUR_TOKEN" }, + "timeout": 30000, + "retries": 3 + } + }, + "oauth": { + "summary": "OAuth server with pre-registered client credentials", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "Partner server", + "url": "https://mcp.partner.example.com/mcp", + "authType": "oauth", + "oauthClientId": "sim-client", + "oauthClientSecret": "YOUR_CLIENT_SECRET" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The MCP server was registered.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/McpServerData" }, + "example": { + "data": { + "mcpServer": { + "id": "mcp-3f7a9c21", + "name": "Docs server", + "description": "Internal documentation tools", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "timeout": 30000, + "retries": 3, + "enabled": true, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 0, + "createdAt": "2025-06-20T14:02:11.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/mcp-servers/{id}": { + "get": { + "operationId": "getMcpServer", + "summary": "Get MCP Server", + "description": "Fetch a single MCP server by id. Configured request header values and the OAuth client secret are never returned.", + "tags": ["MCP Servers"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/mcp-servers/mcp-3f7a9c21?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/McpServerId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The MCP server.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/McpServerData" }, + "example": { + "data": { + "mcpServer": { + "id": "mcp-3f7a9c21", + "name": "Docs server", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "enabled": true, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 7, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "patch": { + "operationId": "updateMcpServer", + "summary": "Update MCP Server", + "description": "Update an MCP server's configuration. Only the fields you send are changed. Requires `write` permission on the workspace.\n\n`url` is immutable: a server's id is derived from its URL, so re-pointing it would leave the id hashing an address the server no longer uses and allow two servers on one URL. Sending a different `url` returns `400` — delete the server and create one at the new address. Sending the URL it already has is accepted, so a full-object PATCH still works.\n\nChanging the auth type or the OAuth client credentials invalidates any existing OAuth grant for the server and resets its connection state.", + "tags": ["MCP Servers"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/mcp-servers/mcp-3f7a9c21\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"enabled\": false\n }'" + } + ], + "parameters": [{ "$ref": "#/components/parameters/McpServerId" }], + "requestBody": { + "required": true, + "description": "The fields to change. `workspaceId` is required so the request is tenant-scoped.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateMcpServerBody" }, + "examples": { + "disable": { + "summary": "Disable a server", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "enabled": false + } + }, + "rotateHeaders": { + "summary": "Rotate the auth header", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "headers": { "Authorization": "Bearer NEW_TOKEN" } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated MCP server.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/McpServerData" }, + "example": { + "data": { + "mcpServer": { + "id": "mcp-3f7a9c21", + "name": "Docs server", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "enabled": false, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 7, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-21T08:30:00.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "delete": { + "operationId": "deleteMcpServer", + "summary": "Delete MCP Server", + "description": "Remove an MCP server from the workspace and revoke any OAuth tokens issued for it. Requires `write` permission on the workspace. Workflows that referenced the server's tools keep their blocks but can no longer call it.", + "tags": ["MCP Servers"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/mcp-servers/mcp-3f7a9c21?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/McpServerId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The MCP server was deleted.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/DeleteAcknowledgement" }, + "example": { "data": { "id": "mcp-3f7a9c21", "deleted": true } } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/skills": { + "get": { + "operationId": "listSkills", + "summary": "List Skills", + "description": "List the skills available in a workspace. Built-in template skills that ship with Sim are included and are marked `readOnly: true`.\n\nSkill bodies can be up to 50 000 characters, so the list returns summaries only — fetch `GET /api/v2/skills/{id}` for a skill's `content`. The per-workspace set is small and bounded, so the full set is returned as a single page and `nextCursor` is always `null`.", + "tags": ["Skills"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/skills?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the skill `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", + "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by. Built-in skills have no stored timestamps and sort as if created at the Unix epoch.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "createdAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" } + } + ], + "responses": { + "200": { + "description": "Skills available in the workspace.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The skills available in the workspace, without their bodies.", + "items": { "$ref": "#/components/schemas/SkillSummary" } + }, + "nextCursor": { "$ref": "#/components/schemas/NextCursor" } + } + }, + "example": { + "data": [ + { + "id": "deploy-workflow", + "name": "deploy-workflow", + "description": "How to deploy a finished workflow", + "readOnly": true, + "createdAt": "1970-01-01T00:00:00.000Z", + "updatedAt": "1970-01-01T00:00:00.000Z" + }, + { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "refund-policy", + "description": "How support should handle refund requests", + "readOnly": false, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "post": { + "operationId": "createSkill", + "summary": "Create Skill", + "description": "Create a skill in a workspace. Requires `write` permission on the workspace, and the creator becomes an editor of the new skill.\n\n`name` must be kebab-case and unique in the workspace; names reserved by built-in skills are rejected. Unlike the internal endpoint this creates exactly one skill and answers with it, not with the whole workspace list.", + "tags": ["Skills"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/skills\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"refund-policy\",\n \"description\": \"How support should handle refund requests\",\n \"content\": \"# Refund policy\\n\\nAlways check the order date first.\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The skill to create.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateSkillBody" }, + "examples": { + "refundPolicy": { + "summary": "A support playbook", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "refund-policy", + "description": "How support should handle refund requests", + "content": "# Refund policy\n\nAlways check the order date first." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The skill was created.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SkillData" }, + "example": { + "data": { + "skill": { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "refund-policy", + "description": "How support should handle refund requests", + "content": "# Refund policy\n\nAlways check the order date first.", + "readOnly": false, + "createdAt": "2025-06-20T14:02:11.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/skills/{id}": { + "get": { + "operationId": "getSkill", + "summary": "Get Skill", + "description": "Fetch a single skill by id, including its full `content`. Built-in template skills resolve here too and are marked `readOnly: true`.", + "tags": ["Skills"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/skills/V1StGXR8Z5jdHi6BmyT?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/SkillId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The skill.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SkillData" }, + "example": { + "data": { + "skill": { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "refund-policy", + "description": "How support should handle refund requests", + "content": "# Refund policy\n\nAlways check the order date first.", + "readOnly": false, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "patch": { + "operationId": "updateSkill", + "summary": "Update Skill", + "description": "Update a skill. Only the fields you send are changed, so a partial edit never clobbers a concurrent change to a field you did not send.\n\nRequires skill editor access — an explicit editor grant on the skill, or workspace admin. Built-in skills are read-only and are rejected.", + "tags": ["Skills"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/skills/V1StGXR8Z5jdHi6BmyT\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"description\": \"Updated refund guidance\"\n }'" + } + ], + "parameters": [{ "$ref": "#/components/parameters/SkillId" }], + "requestBody": { + "required": true, + "description": "The fields to change. At least one of `name`, `description`, or `content` is required.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateSkillBody" }, + "examples": { + "editDescription": { + "summary": "Change the description only", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "description": "Updated refund guidance" + } + }, + "replaceContent": { + "summary": "Replace the skill body", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "content": "# Refund policy\n\nCheck the order date, then the payment method." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated skill.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SkillData" }, + "example": { + "data": { + "skill": { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "refund-policy", + "description": "Updated refund guidance", + "content": "# Refund policy\n\nAlways check the order date first.", + "readOnly": false, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-21T08:30:00.000Z" + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "delete": { + "operationId": "deleteSkill", + "summary": "Delete Skill", + "description": "Delete a skill from the workspace. Requires skill editor access — an explicit editor grant on the skill, or workspace admin. Built-in skills are read-only and are rejected.", + "tags": ["Skills"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/skills/V1StGXR8Z5jdHi6BmyT?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/SkillId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The skill was deleted.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/DeleteAcknowledgement" }, + "example": { "data": { "id": "V1StGXR8Z5jdHi6BmyT", "deleted": true } } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/custom-tools": { + "get": { + "operationId": "listCustomTools", + "summary": "List Custom Tools", + "description": "List the custom tools defined in a workspace. Custom tools are code-backed functions agents can call, declared with an OpenAI-style function schema.\n\nOnly workspace tools are returned — legacy personal tools, which predate workspace scoping and belong to a single user, are not part of the public API. The per-workspace set is small and bounded, so the full set is returned as a single page and `nextCursor` is always `null`.", + "tags": ["Custom Tools"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/custom-tools?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the custom tool `title`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", + "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by.", + "schema": { + "type": "string", + "enum": ["title", "createdAt", "updatedAt"], + "default": "createdAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" } + } + ], + "responses": { + "200": { + "description": "Custom tools defined in the workspace.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The custom tools defined in the workspace.", + "items": { "$ref": "#/components/schemas/CustomTool" } + }, + "nextCursor": { "$ref": "#/components/schemas/NextCursor" } + } + }, + "example": { + "data": [ + { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { "orderId": { "type": "string" } }, + "required": ["orderId"] + } + } + }, + "code": "const res = await fetch(`https://api.example.com/orders/${orderId}`)\nreturn await res.json()", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "post": { + "operationId": "createCustomTool", + "summary": "Create Custom Tool", + "description": "Create a custom tool in a workspace. Requires `write` permission on the workspace.\n\n`title` must be unique within the workspace — tools resolve by title at call time, so a duplicate returns `409 CONFLICT`. `code` is the tool body, executed in Sim's sandboxed function runtime with the schema's parameters bound as variables.", + "tags": ["Custom Tools"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/custom-tools\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"title\": \"lookup_order\",\n \"schema\": {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"lookup_order\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": { \"orderId\": { \"type\": \"string\" } },\n \"required\": [\"orderId\"]\n }\n }\n },\n \"code\": \"return { ok: true }\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The custom tool to create.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateCustomToolBody" }, + "examples": { + "lookupOrder": { + "summary": "A tool that calls an internal API", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { "orderId": { "type": "string" } }, + "required": ["orderId"] + } + } + }, + "code": "const res = await fetch(`https://api.example.com/orders/${orderId}`)\nreturn await res.json()" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The custom tool was created.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CustomToolData" }, + "example": { + "data": { + "customTool": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "parameters": { + "type": "object", + "properties": { "orderId": { "type": "string" } }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }", + "createdAt": "2025-06-20T14:02:11.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/custom-tools/{id}": { + "get": { + "operationId": "getCustomTool", + "summary": "Get Custom Tool", + "description": "Fetch a single custom tool by id, scoped to the workspace.", + "tags": ["Custom Tools"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/custom-tools/V1StGXR8Z5jdHi6BmyT?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/CustomToolId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The custom tool.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CustomToolData" }, + "example": { + "data": { + "customTool": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "parameters": { + "type": "object", + "properties": { "orderId": { "type": "string" } }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "patch": { + "operationId": "updateCustomTool", + "summary": "Update Custom Tool", + "description": "Update a custom tool. Only the fields you send are changed; omitted fields keep their stored values. Requires `write` permission on the workspace.\n\nRenaming onto a title another tool already uses returns `409 CONFLICT`.", + "tags": ["Custom Tools"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/custom-tools/V1StGXR8Z5jdHi6BmyT\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"code\": \"return { ok: false }\"\n }'" + } + ], + "parameters": [{ "$ref": "#/components/parameters/CustomToolId" }], + "requestBody": { + "required": true, + "description": "The fields to change. At least one of `title`, `schema`, or `code` is required.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateCustomToolBody" }, + "examples": { + "editCode": { + "summary": "Replace the implementation only", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "code": "return { ok: false }" + } + }, + "rename": { + "summary": "Rename the tool", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "title": "find_order" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated custom tool.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CustomToolData" }, + "example": { + "data": { + "customTool": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "parameters": { + "type": "object", + "properties": { "orderId": { "type": "string" } }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: false }", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-21T08:30:00.000Z" + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "delete": { + "operationId": "deleteCustomTool", + "summary": "Delete Custom Tool", + "description": "Delete a custom tool from the workspace. Requires `write` permission on the workspace. Agent blocks that referenced the tool keep their configuration but can no longer call it.", + "tags": ["Custom Tools"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/custom-tools/V1StGXR8Z5jdHi6BmyT?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/CustomToolId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The custom tool was deleted.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/DeleteAcknowledgement" }, + "example": { "data": { "id": "V1StGXR8Z5jdHi6BmyT", "deleted": true } } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/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" } + }, + { + "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": { + "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" } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the credential `displayName`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", + "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by.", + "schema": { + "type": "string", + "enum": ["displayName", "createdAt", "updatedAt"], + "default": "createdAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" } + } + ], + "responses": { + "200": { + "description": "Credentials visible to the caller in the workspace.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The credentials visible to the caller.", + "items": { "$ref": "#/components/schemas/Credential" } + }, + "nextCursor": { "$ref": "#/components/schemas/NextCursor" } + } + }, + "example": { + "data": [ + { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom account acct_123", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "envKey": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "post": { + "operationId": "createCredential", + "summary": "Create Credential", + "description": "Create a workspace credential. Requires `write` permission on the workspace; the creator becomes an admin of the credential.\n\n`oauth` credentials **cannot** be created here — they are minted by the interactive OAuth connect flow and bound to an account you authorized in a browser. The creatable types are:\n\n- `env_workspace` — a secret stored under `envKey`, available to everyone in the workspace.\n- `env_personal` — the same, scoped to you.\n- `service_account` — a provider secret (`serviceAccountJson`, `apiToken` + `domain`, `clientId` + `clientSecret` + `orgId`, …). The secret is verified against the provider before it is stored.\n\nEvery secret field is write-only and is never returned. Creation is idempotent on the credential's source (the account, the env key, or the provider + name), so re-issuing the same create returns the existing credential rather than a duplicate.", + "tags": ["Credentials"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/credentials\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"type\": \"env_workspace\",\n \"envKey\": \"STRIPE_API_KEY\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The credential to create.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateCredentialBody" }, + "examples": { + "workspaceEnvVar": { + "summary": "A workspace-wide environment secret", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "type": "env_workspace", + "envKey": "STRIPE_API_KEY" + } + }, + "clientCredentialServiceAccount": { + "summary": "A client-credentials service account", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "type": "service_account", + "providerId": "zoom-service-account", + "clientId": "YOUR_CLIENT_ID", + "clientSecret": "YOUR_CLIENT_SECRET", + "orgId": "YOUR_ACCOUNT_ID" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The credential exists with this source. Returned whether it was inserted now or already present.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CredentialData" }, + "example": { + "data": { + "credential": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "env_workspace", + "displayName": "STRIPE_API_KEY", + "description": null, + "providerId": null, + "accountId": null, + "envKey": "STRIPE_API_KEY", + "hasServiceAccountKey": false, + "role": "admin", + "createdAt": "2025-06-20T14:02:11.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" }, + "503": { "$ref": "#/components/responses/ServiceUnavailable" } + } + } + }, + "/api/v2/credentials/{id}": { + "get": { + "operationId": "getCredential", + "summary": "Get Credential", + "description": "Fetch a single credential. Secret material is never returned — `hasServiceAccountKey` tells you whether one is stored.\n\nA credential you have no grant on answers `404`, not `403`, so its existence is never disclosed to someone who cannot use it.", + "tags": ["Credentials"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/credentials/7c9e6679-7425-40de-944b-e07fc1f90ae7?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/CredentialId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The credential.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CredentialData" }, + "example": { + "data": { + "credential": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom account acct_123", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "envKey": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "patch": { + "operationId": "updateCredential", + "summary": "Update Credential", + "description": "Rename a credential, change its description, or rotate its stored secret. Requires credential admin \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/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 15757b19f1e..00f523d23eb 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -2,218 +2,2434 @@ "openapi": "3.1.0", "info": { "title": "Sim Tables API v2", - "version": "2.0.0-preview", - "description": "Read access to Sim tables with the typed predicate filter grammar and opaque cursor pagination. This surface is feature-gated (`tables-v2-api`): when the flag is off for the caller, every endpoint returns 404 as if it does not exist. Filters are predicate trees — `{\"all\": [...]}` (AND) or `{\"any\": [...]}` (OR) groups whose members are `{field, op, value}` conditions or nested groups. Built-in columns `id`, `createdAt`, and `updatedAt` (camelCase) are filterable and sortable alongside user columns." + "description": "Version 2 of the Sim Tables API for managing tables, their column schemas, and rows of structured data. v2 standardizes every endpoint on a single response family: a single resource is returned as `{ data }`, lists are returned as `{ data, nextCursor }` with opaque cursor pagination, and errors are returned as `{ error: { code, message, details? } }`. Rate-limit state is carried in `X-RateLimit-*` response headers. Authenticate every request with the `X-API-Key` header. Row `data` is always keyed by column name.", + "version": "2.0.0", + "contact": { + "name": "Sim Support", + "email": "help@sim.ai", + "url": "https://www.sim.ai" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } }, - "servers": [{ "url": "https://www.sim.ai" }], - "security": [{ "apiKey": [] }], + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Tables", + "description": "Manage tables, columns, and rows for structured data storage (v2 API)." + } + ], + "security": [ + { + "apiKey": [] + } + ], "paths": { "/api/v2/tables": { "get": { - "operationId": "v2ListTables", + "operationId": "listTables", "summary": "List Tables", - "description": "List every table in a workspace with its column schema and row count.", - "tags": ["Tables v2"], + "description": "List all tables in a workspace. Returns the full bounded set of tables for the workspace as a single page, so `nextCursor` is always null.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], "parameters": [ { - "name": "workspaceId", + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "folderId", "in": "query", - "required": true, + "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": { + "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 \u2014 rename it, change its type, or toggle its required/unique constraints. Provide at least one field in `updates`. Returns the table's full column list after the change.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/columns\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"columnName\": \"phone\",\n \"updates\": {\n \"name\": \"phone_number\",\n \"required\": true\n }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace, the current column name, and the fields to change.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateColumnBody" + } + } + } + }, + "responses": { + "200": { + "description": "The column was updated.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ColumnsEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTableColumn", + "summary": "Delete Column", + "description": "Delete a column from the table schema by name. A table must always keep at least one column. Returns the table's full column list after the change.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/columns\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"columnName\": \"phone_number\"\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace and the name of the column to delete.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteColumnBody" + } + } + } + }, + "responses": { + "200": { + "description": "The column was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ColumnsEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/rows": { + "get": { + "operationId": "listTableRows", + "summary": "List rows", + "description": "Plain cursor page over the default row order. Filtering and sorting are not part of this surface \u2014 use `POST /api/v2/tables/{tableId}/query` for predicate-filtered, sorted reads. The cursor is opaque; page by passing the previous response's `nextCursor` back as `cursor` and stop when it is `null`.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows?workspaceId=YOUR_WORKSPACE_ID&limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "$ref": "#/components/parameters/LimitQuery" + }, + { + "$ref": "#/components/parameters/CursorQuery" + } + ], + "responses": { + "200": { + "description": "Rows matching the query.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RowListEnvelope" + }, + "example": { + "data": [ + { + "id": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07", + "data": { + "email": "jane@example.com", + "name": "Jane Doe", + "age": 30 + }, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + ], + "nextCursor": "eyJvZmZzZXQiOjUwfQ==" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createTableRows", + "summary": "Create Rows", + "description": "Insert one or many rows. Send a single-row body (`{ data }`) to insert one row, or a batch body (`{ rows }`) to insert up to 1000 rows in one request. The response shape mirrors the request: a single insert returns `{ data: { row } }`, a batch insert returns `{ data: { rows, insertedCount } }`. Row `data` is keyed by column name.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"data\": {\n \"email\": \"user@example.com\",\n \"name\": \"Jane Doe\"\n }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "Either a single-row payload or a batch payload.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRowsBody" + }, + "examples": { + "single": { + "summary": "Insert a single row", + "value": { + "workspaceId": "YOUR_WORKSPACE_ID", + "data": { + "email": "user@example.com", + "name": "Jane Doe" + } + } + }, + "batch": { + "summary": "Insert multiple rows", + "value": { + "workspaceId": "YOUR_WORKSPACE_ID", + "rows": [ + { + "email": "a@example.com", + "name": "Ada" + }, + { + "email": "b@example.com", + "name": "Babbage" + } + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The row(s) were inserted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRowsResponse" + }, + "examples": { + "single": { + "summary": "Single insert response", + "value": { + "data": { + "row": { + "id": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07", + "data": { + "email": "user@example.com", + "name": "Jane Doe" + }, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + } + } + }, + "batch": { + "summary": "Batch insert response", + "value": { + "data": { + "rows": [ + { + "id": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "data": { + "email": "a@example.com", + "name": "Ada" + }, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + }, + { + "id": "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85", + "data": { + "email": "b@example.com", + "name": "Babbage" + }, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + ], + "insertedCount": 2 + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "operationId": "updateTableRows", + "summary": "Update Rows by Filter", + "description": "Bulk-update every row matching a filter, applying the same partial `data` patch to each. The filter must contain at least one condition. `updatedRowIds` is always returned (empty when nothing matched).", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PUT \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"filter\": { \"status\": \"pending\" },\n \"data\": { \"status\": \"active\" }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace, a non-empty filter, the patch data, and an optional row cap.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRowsByFilterBody" + } + } + } + }, + "responses": { + "200": { + "description": "The matching rows were updated.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRowsEnvelope" + }, + "example": { + "data": { + "updatedCount": 3, + "updatedRowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85", + "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + ] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTableRows", + "summary": "Delete Rows", + "description": "Delete rows in bulk, either by a non-empty filter or by an explicit list of row ids. Provide exactly one of `filter` or `rowIds`. For id-based deletes the response also reports `requestedCount` and any `missingRowIds`; these fields are omitted for filter-based deletes.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"rowIds\": [\"row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93\", \"row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85\"]\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace and either a non-empty filter or an explicit list of row ids.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteRowsBody" + }, + "examples": { + "byIds": { + "summary": "Delete specific rows by id", + "value": { + "workspaceId": "YOUR_WORKSPACE_ID", + "rowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ] + } + }, + "byFilter": { + "summary": "Delete rows matching a filter", + "value": { + "workspaceId": "YOUR_WORKSPACE_ID", + "filter": { + "all": [ + { + "field": "status", + "op": "eq", + "value": "archived" + } + ] + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The rows were deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteRowsEnvelope" + }, + "examples": { + "byIds": { + "summary": "Id-based delete response", + "value": { + "data": { + "deletedCount": 2, + "deletedRowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ], + "requestedCount": 2, + "missingRowIds": [] + } + } + }, + "byFilter": { + "summary": "Filter-based delete response", + "value": { + "data": { + "deletedCount": 5, + "deletedRowIds": ["row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93"] + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/rows/{rowId}": { + "get": { + "operationId": "getTableRow", + "summary": "Get Row", + "description": "Get a single row by id.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/{rowId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/RowId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The requested row.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RowEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "updateTableRow", + "summary": "Update Row", + "description": "Partially update a single row by id. The `data` patch is keyed by column name and merges into the existing row.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/{rowId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"data\": { \"name\": \"Updated Name\" }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/RowId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace and the partial row data to apply.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRowBody" + } + } + } + }, + "responses": { + "200": { + "description": "The row was updated.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RowEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTableRow", + "summary": "Delete Row", + "description": "Delete a single row by id. Returns `deletedCount` and `deletedRowIds`, mirroring the bulk delete shape.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/{rowId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/RowId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The row was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteRowEnvelope" + }, + "example": { + "data": { + "deletedCount": 1, + "deletedRowIds": ["row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07"] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/rows/upsert": { + "post": { + "operationId": "upsertTableRow", + "summary": "Upsert Row", + "description": "Insert a row, or update the existing row that conflicts on a unique column. When `conflictTarget` is omitted the server resolves the conflict against the table's single unique column. The response reports whether the row was inserted or updated.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/upsert\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"data\": { \"email\": \"user@example.com\", \"name\": \"John\" },\n \"conflictTarget\": \"email\"\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace, the row data, and an optional unique column to resolve the conflict against.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertRowBody" + } + } + } + }, + "responses": { + "200": { + "description": "The row was inserted or updated.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertRowEnvelope" + }, + "example": { + "data": { + "row": { + "id": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07", + "data": { + "email": "user@example.com", + "name": "John" + }, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + }, + "operation": "insert" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/query": { + "post": { + "operationId": "queryTableRows", + "summary": "Query Rows", + "description": "Query rows with a typed predicate filter, an ordered sort spec, and opaque cursor pagination. Row `data` is keyed by column NAME; `select` cells return option names, and filter operands on select columns accept option names (resolved case-insensitively).\n\n**Pagination contract:** page by passing the previous response's `nextCursor` back as `cursor`, and stop only when it is `null` \u2014 a page may return fewer than `limit` rows and still have more behind it, so page fullness is never a termination signal. A cursor is bound to the exact query shape it was minted under: keyset cursors to the default row order, offset cursors (sorted views) to that sort. Replaying one under a different `sort` returns 400 `CURSOR_SORT_CONFLICT`.", + "tags": ["Tables"], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "Bodies over 1 MB are rejected with 413.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1 + }, + "predicate": { + "$ref": "#/components/schemas/Predicate" + }, + "sort": { + "type": "array", + "maxItems": 16, + "description": "Ordered sort spec, highest priority first.", + "items": { + "type": "object", + "required": ["field", "direction"], + "properties": { + "field": { + "type": "string" + }, + "direction": { + "enum": ["asc", "desc"] + } + } + } + }, + "limit": { + "type": "integer", + "minimum": 0, + "maximum": 1000, + "default": 100, + "description": "Omitted \u2192 100. `1..1000` \u2192 page size. `0` \u2192 the ENTIRE matching result in one response; fails with 400 `TABLE_QUERY_RESULT_TOO_LARGE` if it exceeds the 5 MB row-data budget (narrow the predicate or page instead)." + }, + "cursor": { + "type": "string", + "description": "Opaque token from a previous response's `nextCursor`. Pass back verbatim. Mutually exclusive with `sort`." + } + } + }, + "examples": { + "filtered": { + "summary": "Multi-select membership + negated pattern", + "value": { + "workspaceId": "ws_123", + "predicate": { + "all": [ + { + "field": "Color", + "op": "contains", + "value": "Purple" + }, + { + "field": "name", + "op": "nlike", + "value": "G*" + } + ] + }, + "limit": 100 + } + }, + "builtinColumns": { + "summary": "Built-in column range (UTC, timezone-independent)", + "value": { + "workspaceId": "ws_123", + "predicate": { + "all": [ + { + "field": "createdAt", + "op": "gte", + "value": "2026-07-24T03:00:00.000Z" + }, + { + "field": "createdAt", + "op": "lte", + "value": "2026-07-25T02:59:59.999Z" + } + ] + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "A page of matching rows. Served with `Cache-Control: private, no-store`.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RowListEnvelope" + }, + "example": { + "data": [ + { + "id": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07", + "data": { + "name": "Jane Doe", + "status": "active" + }, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "description": "Request body exceeds the 1 MB limit." + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "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." + }, + "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", "currency", "boolean", "date", "json", "select"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "default": false, + "description": "Whether values in this column must be unique across all rows." + }, + "workflowGroupId": { + "type": "string", + "description": "Set when the column is the output of a workflow group." + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelectOption" + }, + "description": "Declared options for a `select` column; absent on other types." + }, + "multiple": { + "type": "boolean", + "description": "A `select` column that accepts multiple options per cell." + }, + "currencyCode": { + "type": "string", + "pattern": "^[A-Za-z]{3}$", + "description": "ISO 4217 currency code for a `currency` column, e.g. `USD`. Normalized to uppercase. Only meaningful on a `currency` column.", + "example": "USD" + } + } + }, + "ColumnInput": { + "type": "object", + "description": "Column definition supplied when creating a table or adding a column.", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "email" + }, + "type": { + "type": "string", + "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "default": false, + "description": "Whether values in this column must be unique across all rows." + }, + "id": { + "type": "string", + "description": "Stable column id. Server-assigned \u2014 normally omit." + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelectOption" + }, + "description": "Declared options for a `select` column; absent on other types." + }, + "multiple": { + "type": "boolean", + "description": "A `select` column that accepts multiple options per cell." + }, + "currencyCode": { + "type": "string", + "pattern": "^[A-Za-z]{3}$", + "description": "ISO 4217 currency code for a `currency` column, e.g. `USD`. Normalized to uppercase. Only meaningful on a `currency` column.", + "example": "USD" + } + } + }, + "Table": { + "type": "object", + "description": "A user-defined table with a typed column schema.", + "required": [ + "id", + "name", + "description", + "schema", + "rowCount", + "maxRows", + "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", "createdAt", "updatedAt"], + "properties": { + "id": { + "type": "string", + "description": "Unique row identifier.", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the row was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the row was last modified." + } + } + }, + "CreateTableBody": { + "type": "object", + "description": "Payload to create a new table.", + "required": ["workspaceId", "name", "schema"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that will own the table." + }, + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 128, + "description": "Table name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "contacts" + }, + "description": { + "type": "string", + "maxLength": 500, + "description": "Optional description of the table." + }, + "schema": { + "type": "object", + "required": ["columns"], + "description": "The table's column schema.", + "properties": { + "columns": { + "type": "array", + "minItems": 1, + "maxItems": 50, + "description": "Column definitions. A table must have between 1 and 50 columns.", + "items": { + "allOf": [ + { + "$ref": "#/components/schemas/ColumnInput" + }, + { + "type": "object", + "properties": { + "workflowGroupId": { + "type": "string", + "description": "Advanced: binds the column to a workflow group's output." + } + } + } + ] + } + } + } + }, + "folderId": { + "type": ["string", "null"], + "description": "Folder to create the table in. Omitted or null creates it at the workspace root." + } + } + }, + "AddColumnBody": { + "type": "object", + "description": "Payload to add a column to a table.", + "required": ["workspaceId", "column"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "column": { + "allOf": [ + { + "$ref": "#/components/schemas/ColumnInput" + }, + { + "type": "object", + "properties": { + "position": { + "type": "integer", + "minimum": 0, + "description": "Zero-based insert position in the column order. Appended at the end when omitted." + } + } + } + ], + "description": "The column definition to add." + } + } + }, + "UpdateColumnBody": { + "type": "object", + "description": "Payload to update an existing column by name.", + "required": ["workspaceId", "columnName", "updates"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "columnName": { + "type": "string", + "description": "The current name of the column to update.", + "example": "phone" + }, + "updates": { + "type": "object", + "description": "Fields to change. Provide at least one.", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "New column name.", + "example": "phone_number" + }, + "type": { + "type": "string", + "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], + "description": "New data type for the column." + }, + "required": { + "type": "boolean", + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "description": "Whether values in this column must be unique across all rows." + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelectOption" + }, + "description": "Declared options for a `select` column; absent on other types." + }, + "multiple": { + "type": "boolean", + "description": "A `select` column that accepts multiple options per cell." + }, + "currencyCode": { + "type": "string", + "pattern": "^[A-Za-z]{3}$", + "description": "ISO 4217 currency code for a `currency` column, e.g. `USD`. Normalized to uppercase. Only meaningful on a `currency` column.", + "example": "USD" + } + } + } + } + }, + "DeleteColumnBody": { + "type": "object", + "description": "Payload to delete a column by name.", + "required": ["workspaceId", "columnName"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "columnName": { + "type": "string", + "description": "The name of the column to delete.", + "example": "phone_number" + } + } + }, + "CreateRowSingleBody": { + "type": "object", + "description": "Insert a single row.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "afterRowId": { + "type": "string", + "minLength": 1, + "description": "Insert directly after this row id. Mutually exclusive with beforeRowId." + }, + "beforeRowId": { + "type": "string", + "minLength": 1, + "description": "Insert directly before this row id. Mutually exclusive with afterRowId." + } + } + }, + "CreateRowBatchBody": { + "type": "object", + "description": "Insert multiple rows in one request.", + "required": ["workspaceId", "rows"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "rows": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "description": "Rows to insert. Each entry is keyed by column name. Up to 1000 rows per request.", + "items": { + "$ref": "#/components/schemas/RowData" + } + } + } + }, + "CreateRowsBody": { + "description": "Either a single-row payload or a batch payload.", + "oneOf": [ + { + "$ref": "#/components/schemas/CreateRowSingleBody" + }, + { + "$ref": "#/components/schemas/CreateRowBatchBody" + } + ] + }, + "UpdateRowsByFilterBody": { + "type": "object", + "description": "Bulk-update rows matching a filter.", + "required": ["workspaceId", "filter", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "filter": { + "$ref": "#/components/schemas/Predicate" + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of matching rows to update." + } + } + }, + "DeleteRowsByFilterBody": { + "type": "object", + "description": "Delete rows matching a filter.", + "required": ["workspaceId", "filter"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "filter": { + "$ref": "#/components/schemas/Predicate" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of matching rows to delete." + } + } + }, + "DeleteRowsByIdsBody": { + "type": "object", + "description": "Delete an explicit list of rows by id.", + "required": ["workspaceId", "rowIds"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "rowIds": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "description": "Row ids to delete. Up to 1000 ids per request.", + "items": { + "type": "string", + "minLength": 1 + } + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of rows to delete." + } + } + }, + "DeleteRowsBody": { + "description": "Provide exactly one of `filter` or `rowIds`.", + "oneOf": [ + { + "$ref": "#/components/schemas/DeleteRowsByFilterBody" + }, + { + "$ref": "#/components/schemas/DeleteRowsByIdsBody" + } + ] + }, + "UpdateRowBody": { + "type": "object", + "description": "Partial update for a single row.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "data": { + "$ref": "#/components/schemas/RowData" + } + } + }, + "UpsertRowBody": { + "type": "object", + "description": "Insert-or-update a row keyed by a unique column.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "conflictTarget": { + "type": "string", + "minLength": 1, + "description": "Name of the unique column to resolve the conflict against. When omitted, the server uses the table's single unique column." + } + } + }, + "TableEnvelope": { + "type": "object", + "description": "A single table wrapped in the v2 data envelope.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["table"], + "properties": { + "table": { + "$ref": "#/components/schemas/Table" + } + } + } + } + }, + "TableListEnvelope": { + "type": "object", + "description": "A page of tables. `nextCursor` is always null because the full bounded workspace set is returned in one page.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Table" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more pages." } - ], - "responses": { - "200": { - "description": "Tables in the workspace. Served with `Cache-Control: private, no-store`.", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["success", "data"], - "properties": { - "success": { "const": true }, - "data": { - "type": "object", - "required": ["tables", "totalCount"], - "properties": { - "tables": { - "type": "array", - "items": { "$ref": "#/components/schemas/TableSummary" } - }, - "totalCount": { "type": "integer" } - } - } - } + } + }, + "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" + } }, - "400": { "$ref": "#/components/responses/ValidationError" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFoundOrGated" }, - "429": { "$ref": "#/components/responses/RateLimited" } + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null on the final page." + } } - } - }, - "/api/v2/tables/{tableId}/query": { - "post": { - "operationId": "v2QueryTableRows", - "summary": "Query Rows", - "description": "Query rows with a typed predicate filter, an ordered sort spec, and opaque cursor pagination. Row `data` is keyed by column NAME; `select` cells return option names, and filter operands on select columns accept option names (resolved case-insensitively).\n\n**Pagination contract:** page by passing the previous response's `nextCursor` back as `cursor`, and stop only when it is `null` — a page may return fewer than `limit` rows and still have more behind it, so page fullness is never a termination signal. A cursor is bound to the exact query shape it was minted under: keyset cursors to the default row order, offset cursors (sorted views) to that sort. Replaying one under a different `sort` returns 400 `CURSOR_SORT_CONFLICT`. `totalCount` is computed on the first page only (requests with a `cursor` return `totalCount: null`).", - "tags": ["Tables v2"], - "parameters": [ + }, + "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": [ { - "name": "tableId", - "in": "path", - "required": true, - "schema": { "type": "string", "minLength": 1 } + "$ref": "#/components/schemas/RowEnvelope" + }, + { + "$ref": "#/components/schemas/BatchInsertRowsEnvelope" } - ], - "requestBody": { - "required": true, - "description": "Bodies over 1 MB are rejected with 413.", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["workspaceId"], - "properties": { - "workspaceId": { "type": "string", "minLength": 1 }, - "predicate": { "$ref": "#/components/schemas/Predicate" }, - "sort": { - "type": "array", - "maxItems": 16, - "description": "Ordered sort spec, highest priority first.", - "items": { - "type": "object", - "required": ["field", "direction"], - "properties": { - "field": { "type": "string" }, - "direction": { "enum": ["asc", "desc"] } - } - } - }, - "limit": { - "type": "integer", - "minimum": 0, - "maximum": 1000, - "default": 100, - "description": "Omitted → 100. `1..1000` → page size. `0` → the ENTIRE matching result in one response; fails with 400 `TABLE_QUERY_RESULT_TOO_LARGE` if it exceeds the 5 MB row-data budget (narrow the predicate or page instead)." - }, - "cursor": { - "type": "string", - "description": "Opaque token from a previous response's `nextCursor`. Pass back verbatim. Mutually exclusive with `sort`." - } - } + ] + }, + "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." }, - "examples": { - "filtered": { - "summary": "Multi-select membership + negated pattern", - "value": { - "workspaceId": "ws_123", - "predicate": { - "all": [ - { "field": "Color", "op": "contains", "value": "Purple" }, - { "field": "name", "op": "nlike", "value": "G*" } - ] - }, - "limit": 100 - } - }, - "builtinColumns": { - "summary": "Built-in column range (UTC, timezone-independent)", - "value": { - "workspaceId": "ws_123", - "predicate": { - "all": [ - { "field": "createdAt", "op": "gte", "value": "2026-07-24T03:00:00.000Z" }, - { "field": "createdAt", "op": "lte", "value": "2026-07-25T02:59:59.999Z" } - ] - } - } + "updatedRowIds": { + "type": "array", + "description": "Ids of the updated rows. Empty when nothing matched.", + "items": { + "type": "string" } } } } - }, - "responses": { - "200": { - "description": "A page of rows. Served with `Cache-Control: private, no-store`.", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["success", "data"], - "properties": { - "success": { "const": true }, - "data": { - "type": "object", - "required": ["rows", "rowCount", "nextCursor"], - "properties": { - "rows": { - "type": "array", - "items": { - "type": "object", - "required": ["id", "data", "createdAt", "updatedAt"], - "properties": { - "id": { "type": "string" }, - "data": { - "type": "object", - "description": "Column-NAME-keyed cell values.", - "additionalProperties": true - }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" } - } - } - }, - "rowCount": { "type": "integer", "description": "Rows in THIS page." }, - "totalCount": { - "type": ["integer", "null"], - "description": "Rows matching the predicate across all pages. First page only; null when a cursor was supplied." - }, - "limit": { "type": ["integer", "null"] }, - "nextCursor": { - "type": ["string", "null"], - "description": "Non-null ⇒ more rows exist. The ONLY termination signal is null." - } - } - } - } + } + }, + "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" } } } - }, - "400": { - "description": "Validation failure. Machine-readable `code` values include `INVALID_FILTER` (unknown column, operator/type mismatch, malformed tree), `INVALID_ORDER`, `INVALID_CURSOR`, `CURSOR_SORT_CONFLICT`, and `TABLE_QUERY_RESULT_TOO_LARGE` (unbounded result exceeded the 5 MB budget).", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/ErrorBody" } + } + } + }, + "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" + } } } - }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFoundOrGated" }, - "413": { - "description": "Request body exceeded the 1 MB cap.", - "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } + } + } + }, + "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." + } } - }, - "429": { "$ref": "#/components/responses/RateLimited" } + } } - } - } - }, - "components": { - "securitySchemes": { - "apiKey": { "type": "apiKey", "in": "header", "name": "X-API-Key" } - }, - "schemas": { + }, "Predicate": { - "description": "A predicate tree: exactly one of `all` (every member must match) or `any` (at least one must). Members are conditions or nested groups; nesting expresses mixed AND/OR logic. Groups must be non-empty (1–100 members), trees at most 10 levels deep and 500 nodes total. Nodes are STRICT: unknown keys, or a node carrying both a group key and condition keys, are rejected rather than ignored.", + "description": "A predicate tree: exactly one of `all` (every member must match) or `any` (at least one must). Members are conditions or nested groups; nesting expresses mixed AND/OR logic. Groups must be non-empty (1\u2013100 members), trees at most 10 levels deep and 500 nodes total. Nodes are STRICT: unknown keys, or a node carrying both a group key and condition keys, are rejected rather than ignored.", "oneOf": [ { "type": "object", @@ -224,7 +2440,9 @@ "type": "array", "minItems": 1, "maxItems": 100, - "items": { "$ref": "#/components/schemas/PredicateNode" } + "items": { + "$ref": "#/components/schemas/PredicateNode" + } } } }, @@ -237,7 +2455,9 @@ "type": "array", "minItems": 1, "maxItems": 100, - "items": { "$ref": "#/components/schemas/PredicateNode" } + "items": { + "$ref": "#/components/schemas/PredicateNode" + } } } } @@ -245,8 +2465,12 @@ }, "PredicateNode": { "oneOf": [ - { "$ref": "#/components/schemas/Predicate" }, - { "$ref": "#/components/schemas/Condition" } + { + "$ref": "#/components/schemas/Predicate" + }, + { + "$ref": "#/components/schemas/Condition" + } ] }, "Condition": { @@ -257,7 +2481,7 @@ "field": { "type": "string", "maxLength": 128, - "description": "Column name, or a built-in: `id`, `createdAt`, `updatedAt` (camelCase — snake_case is treated as a user column and matches nothing)." + "description": "Column name, or a built-in: `id`, `createdAt`, `updatedAt` (camelCase \u2014 snake_case is treated as a user column and matches nothing)." }, "op": { "enum": [ @@ -282,98 +2506,147 @@ "isNull", "isNotNull" ], - "description": "`eq`/`ne`/`in`/`nin` are case-sensitive equality/membership. `contains`/`ncontains`/`startsWith`/`endsWith` are case-insensitive text matches — except on a multi-select column, where `contains`/`ncontains` mean set membership by option name. `like`/`nlike` are case-sensitive and `ilike`/`nilike` case-insensitive patterns with `*` as the only wildcard (literal `%`/`_` match themselves). `isEmpty`/`isNotEmpty` treat null and empty string as empty; `isNull`/`isNotNull` are strict null checks. The four `is*` operators take no `value`. Negated text matches retain rows where the cell is absent. `in`/`nin` require a non-empty array of at most 1000 values; other value-taking operators reject arrays. Select columns accept only equality/membership operators appropriate to their cardinality (single: eq/ne/in/nin; multi: contains/ncontains; both: the `is*` checks)." + "description": "`eq`/`ne`/`in`/`nin` are case-sensitive equality/membership. `contains`/`ncontains`/`startsWith`/`endsWith` are case-insensitive text matches \u2014 except on a multi-select column, where `contains`/`ncontains` mean set membership by option name. `like`/`nlike` are case-sensitive and `ilike`/`nilike` case-insensitive patterns with `*` as the only wildcard (literal `%`/`_` match themselves). `isEmpty`/`isNotEmpty` treat null and empty string as empty; `isNull`/`isNotNull` are strict null checks. The four `is*` operators take no `value`. Negated text matches retain rows where the cell is absent. `in`/`nin` require a non-empty array of at most 1000 values; other value-taking operators reject arrays. Select columns accept only equality/membership operators appropriate to their cardinality (single: eq/ne/in/nin; multi: contains/ncontains; both: the `is*` checks)." }, "value": { "description": "Operand. Omit for the `is*` operators. Ranges on `number` columns require numbers, on `date` columns ISO strings (compared as UTC, independent of any session timezone); ranges on `boolean`/`json` columns are rejected." } } }, - "TableSummary": { - "type": "object", - "required": ["id", "name", "schema", "rowCount", "maxRows", "createdAt", "updatedAt"], - "properties": { - "id": { "type": "string" }, - "name": { "type": "string" }, - "description": { "type": ["string", "null"] }, - "schema": { - "type": "object", - "properties": { - "columns": { - "type": "array", - "items": { - "type": "object", - "required": ["name", "type"], - "properties": { - "id": { "type": "string" }, - "name": { "type": "string" }, - "type": { "enum": ["string", "number", "boolean", "date", "json", "select"] }, - "required": { "type": "boolean" }, - "unique": { "type": "boolean" }, - "options": { - "type": "array", - "description": "Declared choices on a `select` column.", - "items": { - "type": "object", - "properties": { "id": { "type": "string" }, "name": { "type": "string" } } - } - }, - "multiple": { "type": "boolean" } - } - } - } - } - }, - "rowCount": { "type": "integer" }, - "maxRows": { "type": "integer" }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" } - } - }, - "ErrorBody": { + "SelectOption": { "type": "object", - "required": ["error"], + "required": ["id", "name"], "properties": { - "error": { + "id": { "type": "string", - "description": "Human-readable message naming the failing field/operator." + "description": "Stable option id \u2014 the value stored in cells." }, - "code": { + "name": { "type": "string", - "description": "Machine-readable code, present on domain validation failures." + "maxLength": 100, + "description": "Display name. Filters on select columns accept names (resolved case-insensitively)." } } } }, "responses": { - "ValidationError": { - "description": "Malformed request.", + "BadRequest": { + "description": "Invalid request. The request body, query parameters, or a JSON-encoded filter/sort failed validation. Inspect `error.details` for field-level issues.", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { + "path": "schema.columns", + "message": "Table must have at least one column" + } + ] + } + } + } } }, "Unauthorized": { - "description": "Missing or invalid API key.", + "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + } + } + } } }, "Forbidden": { - "description": "The key's workspace scope does not cover this workspace, or the caller lacks read access.", + "description": "Access denied. The API key cannot access the target workspace, or a plan limit (such as the maximum number of tables) has been reached.", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Access denied" + } + } + } } }, - "NotFoundOrGated": { - "description": "Table not found — or the `tables-v2-api` feature flag is off for this caller, in which case the entire surface answers 404. The gate is evaluated after authorization, so a 404 never distinguishes rollout cohort from missing resource for callers without access.", + "NotFound": { + "description": "The requested table or row was not found. Verify the id is correct and belongs to the specified workspace.", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Table not found" + } + } + } } }, "RateLimited": { - "description": "Rate limit exceeded for this key.", + "description": "Rate limit exceeded. Wait for the duration in the Retry-After header before retrying.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/RetryAfter" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-15T10:31:00.000Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected server error occurred.", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } + "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..40984613cbb --- /dev/null +++ b/apps/docs/openapi-v2-workflows.json @@ -0,0 +1,2646 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 — Workflows", + "description": "Version 2 of the Sim REST API for managing workflows (create, list, inspect, update, delete), their deployment versions, and deployments (deploy, undeploy, rollback).\n\nThe v2 surface standardizes on a single response family across every endpoint:\n- Single resource: `{ \"data\": T }`\n- List: `{ \"data\": T[], \"nextCursor\": string | null }`\n- Error: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`\n\nLists use an opaque cursor (Stripe/Slack-style): send `limit` and `cursor`, receive `{ data, nextCursor }`. Cursors are opaque tokens — pass back the `nextCursor` from the previous page verbatim and stop when it is `null`. Total counts are not returned on lists.\n\nRate-limit state is carried in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` response headers (not in the body). A throttled request returns `429` with a `Retry-After` header.\n\nAuthenticate every request with an API key in the `X-API-Key` header.", + "version": "2.0.0", + "contact": { + "name": "Sim Support", + "email": "help@sim.ai", + "url": "https://www.sim.ai" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Workflows", + "description": "Create, list, inspect, update, and delete workflows, enumerate their deployment versions, and manage deployments (deploy, undeploy, rollback) on the v2 API." + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/v2/workflows": { + "get": { + "operationId": "listWorkflows", + "summary": "List Workflows", + "description": "Retrieve workflows in a workspace using opaque cursor-based pagination. Results are ordered deterministically; follow `nextCursor` to page through the full set, and stop when it is `null`.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "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" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the workflow `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", + "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by. `position` is the workspace's own manual arrangement of its workflows, which is the default order. The cursor is a keyset over the active sort, so it carries the sort it was minted under. Replaying a cursor after changing `sortBy` or `sortOrder` returns `400`; restart pagination without a cursor instead.", + "schema": { + "type": "string", + "enum": ["position", "name", "createdAt", "updatedAt", "runCount"], + "default": "position" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction. The cursor is a keyset over the active sort, so it carries the sort it was minted under. Replaying a cursor after changing `sortBy` or `sortOrder` returns `400`; restart pagination without a cursor instead.", + "schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" } + } + ], + "responses": { + "200": { + "description": "A page of workflows.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "Workflows for the current page.", + "items": { + "$ref": "#/components/schemas/WorkflowListItem" + } + }, + "nextCursor": { + "type": "string", + "nullable": true, + "description": "Opaque cursor for fetching the next page. `null` when there are no more results." + } + } + }, + "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" + } + } + }, + "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}/versions": { + "get": { + "operationId": "listWorkflowVersionsV2", + "summary": "List Workflow Versions", + "description": "List a workflow's deployment versions, newest first. Every successful deploy appends a version; these are the version numbers `POST /api/v2/workflows/{id}/rollback` accepts. Results are cursor-paginated — follow `nextCursor` and stop when it is `null`.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows/{id}/versions\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of versions to return per page. Must be between 1 and 100.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor returned from a previous request's `nextCursor` field. Omit for the first page.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "A page of deployment versions, newest first.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "Deployment versions for the current page.", + "items": { + "$ref": "#/components/schemas/WorkflowVersion" + } + }, + "nextCursor": { + "type": "string", + "nullable": true, + "description": "Opaque cursor for fetching the next page. `null` when there are no more results." + } + } + }, + "example": { + "data": [ + { + "id": "d41a9f0c-7b25-4e18-9a3d-1c6f0b8e5d24", + "version": 3, + "name": "Adds escalation branch", + "description": "Routes P1 tickets straight to on-call", + "isActive": true, + "createdAt": "2026-06-12T10:30:00.000Z", + "deployedBy": "Ada Lovelace", + "latestOperationStatus": "active" + }, + { + "id": "b70e2c81-4d93-4a17-8f52-93a1c7e0d6b8", + "version": 2, + "name": null, + "description": null, + "isActive": false, + "createdAt": "2026-05-02T09:04:00.000Z", + "deployedBy": "Ada Lovelace", + "latestOperationStatus": null + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}/versions/{version}": { + "get": { + "operationId": "getWorkflowVersionV2", + "summary": "Get Workflow Version", + "description": "Fetch one deployment version and the workflow state it pins. Use this to inspect or diff a version before activating it with `POST /api/v2/workflows/{id}/rollback`.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows/{id}/versions/{version}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + }, + { + "$ref": "#/components/parameters/VersionNumber" + } + ], + "responses": { + "200": { + "description": "The requested deployment version.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/WorkflowVersionDetail" + } + } + }, + "example": { + "data": { + "id": "d41a9f0c-7b25-4e18-9a3d-1c6f0b8e5d24", + "version": 3, + "name": "Adds escalation branch", + "description": "Routes P1 tickets straight to on-call", + "isActive": true, + "createdAt": "2026-06-12T10:30:00.000Z", + "state": { + "blocks": {}, + "edges": [], + "loops": {}, + "parallels": {} + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}/deploy": { + "post": { + "operationId": "deployWorkflow", + "summary": "Deploy Workflow", + "description": "Deploy the workflow's current draft state. Creates a new deployment version, makes it live for API execution, and activates schedules and triggers. Optionally accepts a `name` and `description` for the new version; the request body may be omitted entirely. Returns 404 when the workflow does not exist or you do not have access to it, and 423 when the workflow is locked.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/workflows/{id}/deploy\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"name\": \"Release 4\", \"description\": \"Fixes the agent prompt\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "requestBody": { + "required": false, + "description": "Optional metadata for the new deployment version. The request body may be omitted entirely.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Optional label for the new deployment version.", + "example": "Release 4" + }, + "description": { + "type": "string", + "maxLength": 50000, + "nullable": true, + "description": "Optional summary of what changed in this version.", + "example": "Fixes the agent prompt" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Workflow deployed successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/DeployResult" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "version": 4, + "warnings": [], + "activeDeployment": null, + "latestDeploymentAttempt": null + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "undeployWorkflow", + "summary": "Undeploy Workflow", + "description": "Take the workflow offline. API execution stops and schedules, webhooks, and other deployment side effects are removed. Deployment versions are retained, so the workflow can be deployed again later. Returns 400 when the workflow is not currently deployed, 404 when the workflow does not exist or you do not have access to it, and 423 when the workflow is locked.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/workflows/{id}/deploy\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "responses": { + "200": { + "description": "Workflow undeployed successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/UndeployResult" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": false, + "deployedAt": null, + "warnings": [], + "activeDeployment": null, + "latestDeploymentAttempt": null + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}/rollback": { + "post": { + "operationId": "rollbackWorkflow", + "summary": "Rollback Workflow", + "description": "Roll the live deployment back to a previous deployment version. The workflow must currently be deployed. By default the version immediately preceding the currently active one is re-activated; pass `version` to target a specific deployment version instead. The workflow's draft state is not modified. Returns 400 when the workflow is not deployed or there is no version to roll back to, 404 when the workflow does not exist or you do not have access to it, and 423 when the workflow is locked.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/workflows/{id}/rollback\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"version\": 3}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "requestBody": { + "required": false, + "description": "Optional rollback target. The request body may be omitted entirely to roll back to the version immediately preceding the active one.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "version": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647, + "description": "The deployment version to re-activate. Defaults to the version immediately preceding the active one.", + "example": 3 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Workflow rolled back successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/RollbackResult" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "version": 3, + "warnings": [], + "activeDeployment": null, + "latestDeploymentAttempt": null + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}/export": { + "get": { + "operationId": "exportWorkflow", + "summary": "Export a workflow", + "description": "Exports a workflow as a portable JSON envelope that `POST /api/v2/workflows/import` accepts verbatim. See the payload schema for what sanitization clears.", + "tags": ["Workflows"], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "responses": { + "200": { + "description": "The export payload.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/WorkflowExportPayload" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/import": { + "post": { + "operationId": "importWorkflow", + "summary": "Import a workflow", + "description": "Creates a new workflow in the target workspace from an export payload produced by `GET /api/v2/workflows/{id}/export`. Block, edge, loop and parallel ids are regenerated on import, so the same payload can be imported repeatedly and alongside its source workflow without collisions. Bodies over 10 MB are rejected with 413.", + "tags": ["Workflows"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportWorkflowBody" + } + } + } + }, + "responses": { + "201": { + "description": "The created workflow.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/ImportedWorkflow" + } + } + } + } + } + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "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": "A workflow with the same name already exists and deduplication failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "423": { + "$ref": "#/components/responses/Locked" + } + } + } + }, + "/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": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "WorkspaceId": { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "The unique identifier of the workspace to list workflows from.", + "schema": { + "type": "string", + "minLength": 1, + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + }, + "WorkflowId": { + "name": "id", + "in": "path", + "required": true, + "description": "The unique workflow identifier.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + "VersionNumber": { + "name": "version", + "in": "path", + "required": true, + "description": "The deployment version number, as returned by the version list.", + "schema": { + "type": "integer", + "minimum": 1, + "example": 3 + } + } + }, + "headers": { + "RateLimitLimit": { + "description": "The maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 60 + } + }, + "RateLimitRemaining": { + "description": "The number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 59 + } + }, + "RateLimitReset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-06-29T21:50:00.000Z" + } + } + }, + "schemas": { + "Error": { + "type": "object", + "description": "Canonical v2 error envelope. Every non-2xx response uses this shape.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Stable, machine-readable error code.", + "enum": [ + "BAD_REQUEST", + "UNAUTHORIZED", + "USAGE_LIMIT_EXCEEDED", + "FORBIDDEN", + "NOT_FOUND", + "CONFLICT", + "PAYLOAD_TOO_LARGE", + "UNSUPPORTED_MEDIA_TYPE", + "LOCKED", + "RATE_LIMITED", + "INTERNAL_ERROR" + ] + }, + "message": { + "type": "string", + "description": "Human-readable description of what went wrong." + }, + "details": { + "description": "Optional structured detail about the error (e.g. field-level validation issues). Shape varies by error code; absent when there is nothing to add." + } + } + } + } + }, + "WorkflowListItem": { + "type": "object", + "description": "Summary representation of a workflow returned by the list endpoint.", + "required": [ + "id", + "name", + "description", + "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", + "activeDeployment", + "latestDeploymentAttempt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow is deployed and available for API execution after the operation." + }, + "deployedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the active deployment. `null` when the workflow is not deployed.", + "example": "2026-06-12T10:30:00.000Z" + }, + "warnings": { + "type": "array", + "description": "Non-fatal warnings. Present when trigger, schedule, or MCP side-effect sync is still in progress or needs a redeploy. Empty array when there is nothing to report.", + "items": { + "type": "string" + } + }, + "activeDeployment": { + "type": ["object", "null"], + "description": "Summary of the currently live deployment version, or null when none is active." + }, + "latestDeploymentAttempt": { + "type": ["object", "null"], + "description": "Lifecycle status of the most recent deploy attempt (preparing/activating/active/failed/superseded) — poll this to a terminal state; deploys admit asynchronously, so HTTP success only means the attempt was accepted." + } + } + }, + "DeployResult": { + "description": "Deployment state returned after a successful deploy. `isDeployed` is always `true`.", + "allOf": [ + { + "$ref": "#/components/schemas/DeploymentState" + }, + { + "type": "object", + "properties": { + "version": { + "type": "integer", + "description": "The deployment version that is now active. May be omitted when the version number is unavailable.", + "example": 4 + } + } + } + ] + }, + "UndeployResult": { + "description": "Deployment state returned after a successful undeploy. `isDeployed` is always `false`, `deployedAt` is always `null`, and no `version` is included.", + "allOf": [ + { + "$ref": "#/components/schemas/DeploymentState" + } + ] + }, + "RollbackResult": { + "description": "Deployment state returned after a successful rollback. `isDeployed` is always `true` and `version` identifies the re-activated deployment version.", + "allOf": [ + { + "$ref": "#/components/schemas/DeploymentState" + }, + { + "type": "object", + "required": ["version"], + "properties": { + "version": { + "type": "integer", + "description": "The deployment version that was re-activated.", + "example": 3 + } + } + } + ] + }, + "WorkflowExportPayload": { + "type": "object", + "required": ["version", "exportedAt", "workflow", "state"], + "description": "Portable workflow export envelope. Secret-sanitized: password fields are cleared unless the value is a whole `{{ENV_VAR}}` reference (preserved so the import resolves it in the target workspace), OAuth credentials are cleared, and workspace-scoped bindings (knowledge-base/file/channel/project selectors and id-keyed fields) are cleared rather than carried across as dangling ids — so a re-import is not a byte-for-byte clone: those bindings must be re-selected. Workflow variables are emitted as stored.", + "properties": { + "version": { + "const": "1.0" + }, + "exportedAt": { + "type": "string", + "format": "date-time" + }, + "workflow": { + "type": "object", + "required": ["id", "name", "description", "workspaceId", "folderId"], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": ["string", "null"] + }, + "workspaceId": { + "type": ["string", "null"] + }, + "folderId": { + "type": ["string", "null"] + } + } + }, + "state": { + "type": "object", + "description": "Sanitized workflow graph: `blocks`, `edges`, `loops`, `parallels`, optional `metadata` and `variables`.", + "additionalProperties": true + } + } + }, + "ImportWorkflowBody": { + "type": "object", + "required": ["workspaceId", "workflow"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1 + }, + "folderId": { + "type": "string", + "minLength": 1, + "description": "Target folder; must belong to the workspace and be unlocked." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Overrides the payload's own metadata name." + }, + "description": { + "type": "string", + "maxLength": 2000, + "description": "Overrides the payload's own metadata description." + }, + "workflow": { + "description": "The export envelope emitted by `GET /api/v2/workflows/{id}/export` (its `data` value or the whole response body), a bare workflow state (`{ blocks, edges, ... }`), or a JSON string of either.", + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "object", + "minProperties": 1 + } + ] + } + } + }, + "ImportedWorkflow": { + "type": "object", + "required": [ + "id", + "name", + "description", + "workspaceId", + "folderId", + "createdAt", + "updatedAt" + ], + "description": "The created workflow — the subset of the workflow resource knowable at import time.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": ["string", "null"] + }, + "workspaceId": { + "type": "string" + }, + "folderId": { + "type": ["string", "null"] + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ExecutionError": { + "type": "object", + "required": ["message", "code"], + "description": "Structured execution error. Route on `code` (append-only enum) instead of matching message text. Block fields identify the failing block when attributable — with the executionId they form the reproducible handle to hand a shared workflow's provider.", + "properties": { + "message": { + "type": "string" + }, + "code": { + "enum": [ + "TIMEOUT", + "CANCELLED", + "USAGE_LIMIT_EXCEEDED", + "INVALID_INPUT", + "BLOCK_EXECUTION_FAILED", + "CHILD_WORKFLOW_FAILED", + "OUTPUT_TOO_LARGE", + "EXECUTION_FAILED" + ] + }, + "blockId": { + "type": "string" + }, + "blockName": { + "type": "string" + }, + "blockType": { + "type": "string" + } + } + }, + "ExecutionResource": { + "type": "object", + "required": ["executionId", "workflowId", "status", "output", "error"], + "description": "The execution result resource. An executionId always means 200/202 with data; only pre-execution failures use the error envelope. In-band run failures are status 'failed' with the structured error — never an HTTP error status.", + "properties": { + "executionId": { + "type": "string" + }, + "workflowId": { + "type": "string" + }, + "status": { + "enum": ["completed", "failed", "paused", "cancelled"] + }, + "output": { + "description": "Workflow output (partial output is preserved on failures)." + }, + "error": { + "oneOf": [ + { + "$ref": "#/components/schemas/ExecutionError" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "endedAt": { + "type": "string", + "format": "date-time" + }, + "durationMs": { + "type": "number" + } + } + }, + "CreateWorkflowBody": { + "type": "object", + "description": "Request body for creating a workflow.", + "required": ["workspaceId", "name"], + "additionalProperties": false, + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace to create the workflow in. Requires write access.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Workflow name. Must be unique within the target folder.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "maxLength": 50000, + "nullable": true, + "description": "Optional description of what the workflow does.", + "example": "Routes incoming support tickets and drafts responses" + }, + "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": { + "BadRequest": { + "description": "The request was malformed or failed validation. Inspect `error.details` for field-level issues. Also returned when an operation is not allowed in the current state (e.g. undeploying a workflow that is not deployed).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "workspaceId is required", + "details": [ + { + "path": ["workspaceId"], + "message": "workspaceId is required" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the `X-API-Key` header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + } + } + } + } + }, + "Forbidden": { + "description": "Access denied. You do not have permission to access the requested workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Access denied" + } + } + } + } + }, + "NotFound": { + "description": "The workflow does not exist or you do not have access to it. Existence is not leaked, so an access failure is reported as 404.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Workflow not found" + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The request body exceeds the maximum allowed size.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } + } + } + } + }, + "Locked": { + "description": "The workflow is locked and cannot be modified. Wait for the in-progress operation to finish, then retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "LOCKED", + "message": "Workflow is locked and cannot be modified" + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration specified in the `Retry-After` header before retrying.", + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer", + "example": 30 + } + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-06-29T21:50:00.000Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected error occurred while processing the request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + } + } + } +} diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index 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/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/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts index 99bf39603b6..837aec3a24b 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts @@ -16,12 +16,14 @@ import { NextRequest } from 'next/server' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { + mockExecuteWorkflowService, mockAssertBillingAttributionSnapshot, mockGenerateInternalToken, mockResolveBillingAttribution, mockSerializeBillingAttributionHeader, fetchMock, } = vi.hoisted(() => ({ + mockExecuteWorkflowService: vi.fn(), mockAssertBillingAttributionSnapshot: vi.fn(), mockGenerateInternalToken: vi.fn(), mockResolveBillingAttribution: vi.fn(), @@ -65,6 +67,10 @@ vi.mock('@/lib/core/execution-limits', () => ({ getMaxExecutionTimeout: () => 10_000, })) +vi.mock('@/lib/workflows/executor/execute-service', () => ({ + executeWorkflowService: mockExecuteWorkflowService, +})) + import { DELETE, GET, POST } from '@/app/api/mcp/serve/[serverId]/route' describe('MCP Serve Route', () => { @@ -230,7 +236,7 @@ describe('MCP Serve Route', () => { expect(response.status).toBe(401) }) - it('uses an internal bridge token for private server api_key auth', async () => { + it('executes in-process with the personal-key actor override for private server api_key auth', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ { @@ -251,13 +257,16 @@ describe('MCP Serve Route', () => { apiKeyType: 'personal', }) mockGetUserEntityPermissions.mockResolvedValueOnce('write') - mockGenerateInternalToken.mockResolvedValueOnce('internal-token-user-1') - fetchMock.mockResolvedValueOnce( - new Response(JSON.stringify({ output: { ok: true } }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: true, + executionId: 'exec-1', + workflowId: 'wf-1', + status: 'completed', + aborted: null, + output: { ok: true }, + error: null, + hasResponseBlock: false, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -272,21 +281,25 @@ describe('MCP Serve Route', () => { const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) expect(response.status).toBe(200) - expect(fetchMock).toHaveBeenCalledTimes(1) - const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit - const headers = fetchOptions.headers as Record - expect(headers.Authorization).toBe('Bearer internal-token-user-1') - expect(headers['X-Sim-MCP-Tool-Actor']).toBe('authenticated-user') - expect(headers['x-sim-billing-attribution']).toBe('serialized-attribution') - expect(headers['X-API-Key']).toBeUndefined() - expect(mockGenerateInternalToken).toHaveBeenCalledWith('user-1') + expect(mockExecuteWorkflowService).toHaveBeenCalledTimes(1) + expect(mockExecuteWorkflowService).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'wf-1', + userId: 'user-1', + triggerType: 'mcp', + useAuthenticatedUserAsActor: true, + deploymentVersionId: 'deployment-1', + includeFileBase64: false, + rejectLargeInlineOutput: true, + }) + ) expect(mockResolveBillingAttribution).toHaveBeenCalledWith({ actorUserId: 'user-1', workspaceId: 'ws-1', }) }) - it('forwards internal token for private server session auth', async () => { + it('executes in-process without the actor override for private server session auth', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ { @@ -306,13 +319,16 @@ describe('MCP Serve Route', () => { authType: 'session', }) mockGetUserEntityPermissions.mockResolvedValueOnce('read') - mockGenerateInternalToken.mockResolvedValueOnce('internal-token-user-1') - fetchMock.mockResolvedValueOnce( - new Response(JSON.stringify({ output: { ok: true } }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: true, + executionId: 'exec-1', + workflowId: 'wf-1', + status: 'completed', + aborted: null, + output: { ok: true }, + error: null, + hasResponseBlock: false, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -326,14 +342,12 @@ describe('MCP Serve Route', () => { const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) expect(response.status).toBe(200) - expect(fetchMock).toHaveBeenCalledTimes(1) - const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit - const headers = fetchOptions.headers as Record - expect(headers.Authorization).toBe('Bearer internal-token-user-1') - expect(headers['X-Sim-MCP-Tool-Actor']).toBeUndefined() - expect(headers['x-sim-billing-attribution']).toBe('serialized-attribution') - expect(headers['X-API-Key']).toBeUndefined() - expect(mockGenerateInternalToken).toHaveBeenCalledWith('user-1') + expect(mockExecuteWorkflowService).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + useAuthenticatedUserAsActor: false, + }) + ) expect(mockResolveBillingAttribution).toHaveBeenCalledWith({ actorUserId: 'user-1', workspaceId: 'ws-1', @@ -353,13 +367,16 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - mockGenerateInternalToken.mockResolvedValueOnce('internal-token-owner-1') - fetchMock.mockResolvedValueOnce( - new Response(JSON.stringify({ output: { ok: true } }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: true, + executionId: 'exec-1', + workflowId: 'wf-1', + status: 'completed', + aborted: null, + output: { ok: true }, + error: null, + hasResponseBlock: false, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -380,12 +397,9 @@ describe('MCP Serve Route', () => { }) const attribution = createBillingAttribution('owner-1', 'ws-1') expect(mockAssertBillingAttributionSnapshot).toHaveBeenCalledWith(attribution) - expect(mockSerializeBillingAttributionHeader).toHaveBeenCalledWith(attribution) - const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit - const headers = fetchOptions.headers as Record - expect(headers.Authorization).toBe('Bearer internal-token-owner-1') - expect(headers['x-sim-billing-attribution']).toBe('serialized-attribution') - expect(headers['x-sim-billing-attribution']).not.toBe('caller-controlled-attribution') + expect(mockExecuteWorkflowService).toHaveBeenCalledWith( + expect.objectContaining({ upstreamBillingAttribution: attribution, userId: 'owner-1' }) + ) }) it.each([null, 'ws-other'])( @@ -545,8 +559,7 @@ describe('MCP Serve Route', () => { expect(fetchMock).not.toHaveBeenCalled() }) - it('cancels and rejects oversized workflow execution responses', async () => { - const cancelSpy = vi.fn() + it('maps oversized workflow outputs to the response-direction 413', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ { @@ -559,17 +572,17 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - fetchMock.mockResolvedValueOnce( - new Response( - new ReadableStream({ - cancel: cancelSpy, - }), - { - status: 200, - headers: { 'content-length': String(MCP_BYTE_LIMIT + 1) }, - } - ) - ) + + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: false, + failure: { + kind: 'output_too_large', + statusCode: 413, + message: 'Workflow execution response exceeds maximum size', + code: 'workflow_response_too_large', + executionId: 'exec-1', + }, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -580,17 +593,19 @@ describe('MCP Serve Route', () => { params: { name: 'tool_a', arguments: { q: 'test' } }, }), }) - const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() expect(response.status).toBe(413) - expect(body.error.message).toContain('MCP workflow execution response') - expect(cancelSpy).toHaveBeenCalled() + expect(body.error.data.httpStatus).toBe(413) + // Response-direction 413 keeps the workflow_response_too_large code so + // clients can distinguish it from a request-side payload rejection. + expect(body.error.data.code).toBe('workflow_response_too_large') + expect(body.error.data.executionId).toBe('exec-1') }) - it('cancels and rejects streamed workflow responses that exceed the cap', async () => { - const cancelSpy = vi.fn() + it('surfaces rate-limit failures with Retry-After and the retryable flag', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ { @@ -603,21 +618,17 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - fetchMock.mockResolvedValueOnce( - new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array(MCP_BYTE_LIMIT)) - controller.enqueue(new Uint8Array(1)) - }, - cancel: cancelSpy, - }), - { - status: 200, - headers: { 'content-length': '1' }, - } - ) - ) + + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: false, + failure: { + kind: 'precheck', + statusCode: 429, + message: 'Rate limit exceeded. Please try again later.', + code: 'RATE_LIMIT_EXCEEDED', + retryAfterMs: 9_000, + }, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -628,13 +639,14 @@ describe('MCP Serve Route', () => { params: { name: 'tool_a', arguments: { q: 'test' } }, }), }) - const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() - expect(response.status).toBe(413) - expect(body.error.message).toContain('MCP workflow execution response') - expect(cancelSpy).toHaveBeenCalled() + expect(response.status).toBe(429) + expect(response.headers.get('Retry-After')).toBe('9') + expect(body.error.data.retryable).toBe(true) + expect(body.error.data.code).toBe('RATE_LIMIT_EXCEEDED') }) it('preserves recoverable workflow execution statuses through the MCP bridge', async () => { @@ -650,18 +662,15 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: false, - error: 'Workflow execution request body exceeds maximum size', - }), - { - status: 413, - headers: { 'Content-Type': 'application/json' }, - } - ) - ) + + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: false, + failure: { + kind: 'infra', + statusCode: 503, + message: 'Error checking rate limits', + }, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -672,19 +681,13 @@ describe('MCP Serve Route', () => { params: { name: 'tool_a', arguments: { q: 'test' } }, }), }) - const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() - expect(response.status).toBe(413) - expect(body.error.code).toBe(-32600) - expect(body.error.data.httpStatus).toBe(413) - const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit - const headers = fetchOptions.headers as Record - expect(headers['X-Sim-MCP-Tool-Call']).toBe('true') - expect(JSON.parse(fetchOptions.body as string)).toMatchObject({ - deploymentVersionId: 'deployment-1', - }) + expect(response.status).toBe(503) + expect(body.error.data.httpStatus).toBe(503) + expect(body.error.data.retryable).toBe(true) }) it('preserves downstream attributed usage admission rejections', async () => { @@ -700,18 +703,15 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: false, - error: 'Workspace usage limit exceeded.', - }), - { - status: 402, - headers: { 'Content-Type': 'application/json' }, - } - ) - ) + + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: false, + failure: { + kind: 'precheck', + statusCode: 402, + message: 'Workspace usage limit exceeded.', + }, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -722,16 +722,16 @@ describe('MCP Serve Route', () => { params: { name: 'tool_a' }, }), }) - const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() expect(response.status).toBe(402) - expect(body.error.message).toBe('Workspace usage limit exceeded.') expect(body.error.data.httpStatus).toBe(402) + expect(body.error.message).toBe('Workspace usage limit exceeded.') }) - it('preserves upstream error status when workflow response is not JSON', async () => { + it('maps the sync timeout onto the retryable 408 shape', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ { @@ -744,7 +744,17 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - fetchMock.mockResolvedValueOnce(new Response('gateway timeout', { status: 408 })) + + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: true, + executionId: 'exec-1', + workflowId: 'wf-1', + status: 'failed', + aborted: 'timeout', + output: undefined, + error: { message: 'Execution timed out after 60000ms', code: 'TIMEOUT' }, + hasResponseBlock: false, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -755,13 +765,14 @@ describe('MCP Serve Route', () => { params: { name: 'tool_a', arguments: { q: 'test' } }, }), }) - const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() expect(response.status).toBe(408) expect(body.error.data.httpStatus).toBe(408) expect(body.error.data.retryable).toBe(true) + expect(body.error.data.code).toBe('TIMEOUT') }) it('preserves falsy workflow outputs in MCP tool results', async () => { @@ -777,12 +788,17 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - fetchMock.mockResolvedValueOnce( - new Response(JSON.stringify({ success: true, output: false }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) + + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: true, + executionId: 'exec-1', + workflowId: 'wf-1', + status: 'completed', + aborted: null, + output: false, + error: null, + hasResponseBlock: false, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -793,15 +809,16 @@ describe('MCP Serve Route', () => { params: { name: 'tool_a', arguments: { q: 'test' } }, }), }) - const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() expect(response.status).toBe(200) expect(body.result.content[0].text).toBe('false') + expect(body.result.isError).toBe(false) }) - it('serializes missing workflow output without failing the MCP tool call', async () => { + it('serializes failed runs with the structured error and child executionId', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ { @@ -814,12 +831,23 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - fetchMock.mockResolvedValueOnce( - new Response(JSON.stringify({ success: true }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) + + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: true, + executionId: 'exec-9', + workflowId: 'wf-1', + status: 'failed', + aborted: null, + output: { partial: true }, + error: { + message: 'Invalid credentials', + code: 'BLOCK_EXECUTION_FAILED', + blockId: 'b-1', + blockName: 'Send Email', + blockType: 'gmail', + }, + hasResponseBlock: false, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -830,27 +858,32 @@ describe('MCP Serve Route', () => { params: { name: 'tool_a', arguments: { q: 'test' } }, }), }) - const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() expect(response.status).toBe(200) - expect(body.result.content[0].text).toContain('"success": true') + expect(body.result.isError).toBe(true) + const text = body.result.content[0].text + expect(text).toContain('"executionId": "exec-9"') + expect(text).toContain('"code": "BLOCK_EXECUTION_FAILED"') + expect(text).toContain('"blockName": "Send Email"') }) - it('serializes non-object workflow JSON responses from response blocks', async () => { + it('serializes non-object workflow outputs', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ { id: 'server-1', - name: 'Private Server', + name: 'Public Server', workspaceId: 'ws-1', - isPublic: false, + isPublic: true, createdBy: 'owner-1', }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) + hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ success: true, userId: 'user-1', @@ -858,12 +891,16 @@ describe('MCP Serve Route', () => { apiKeyType: 'personal', }) mockGetUserEntityPermissions.mockResolvedValueOnce('write') - fetchMock.mockResolvedValueOnce( - new Response(JSON.stringify(['a', 'b']), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: true, + executionId: 'exec-1', + workflowId: 'wf-1', + status: 'completed', + aborted: null, + output: ['a', 'b'], + error: null, + hasResponseBlock: false, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -875,12 +912,12 @@ describe('MCP Serve Route', () => { params: { name: 'tool_a', arguments: { q: 'test' } }, }), }) - const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() expect(response.status).toBe(200) - expect(body.result.content[0].text).toBe(JSON.stringify(['a', 'b'], null, 2)) + expect(JSON.parse(body.result.content[0].text)).toEqual(['a', 'b']) }) it('rejects duplicate tool names instead of choosing an arbitrary workflow', async () => { @@ -917,8 +954,7 @@ describe('MCP Serve Route', () => { expect(fetchMock).not.toHaveBeenCalled() }) - it('aborts the internal workflow fetch when the MCP client disconnects', async () => { - const requestAbortController = new AbortController() + it('maps a client-aborted run onto ConnectionClosed', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ { @@ -931,36 +967,34 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - fetchMock.mockImplementationOnce((_url, init: RequestInit) => { - const signal = init.signal as AbortSignal - return new Promise((_resolve, reject) => { - signal.addEventListener( - 'abort', - () => { - reject(Object.assign(new Error('aborted'), { name: 'AbortError' })) - }, - { once: true } - ) - requestAbortController.abort() - }) - }) - const req = new NextRequest( - new Request('http://localhost:3000/api/mcp/serve/server-1', { - method: 'POST', - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'tools/call', - params: { name: 'tool_a', arguments: { q: 'test' } }, - }), - signal: requestAbortController.signal, - }) - ) + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: true, + executionId: 'exec-1', + workflowId: 'wf-1', + status: 'cancelled', + aborted: 'client', + output: undefined, + error: { message: 'Client cancelled request', code: 'CANCELLED' }, + hasResponseBlock: false, + }) + const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { + method: 'POST', + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'tool_a', arguments: { q: 'test' } }, + }), + }) const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() + expect(response.status).toBe(499) + expect(body.error.data.httpStatus).toBe(499) + expect(body.error.data.executionId).toBe('exec-1') }) it('paginates tools/list by tool count', async () => { diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index e342f35e343..a60436ec6cf 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -35,34 +35,29 @@ import { mcpToolCallParamsSchema, } from '@/lib/api/contracts/mcp' import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid' -import { generateInternalToken } from '@/lib/auth/internal' import { assertBillingAttributionSnapshot, - BILLING_ATTRIBUTION_HEADER, type BillingAttributionSnapshot, resolveBillingAttribution, - serializeBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' +import { generateRequestId } from '@/lib/core/utils/request' import { assertContentLengthWithinLimit, assertKnownSizeWithinLimit, isPayloadSizeLimitError, - readResponseTextWithLimit, readStreamToBufferWithLimit, } from '@/lib/core/utils/stream-limits' -import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { SIM_VIA_HEADER } from '@/lib/execution/call-chain' +import { parseCallChain, SIM_VIA_HEADER } from '@/lib/execution/call-chain' import { MAX_MCP_PARAMETER_SCHEMA_BYTES, MAX_MCP_TOOLS_LIST_RESPONSE_BYTES, MAX_MCP_TOOLS_PER_SERVER, MAX_MCP_WORKFLOW_RESPONSE_BYTES, - MCP_TOOL_BRIDGE_ACTOR_HEADER, - MCP_TOOL_BRIDGE_HEADER, } from '@/lib/mcp/constants' import { getMeaningfulWorkflowDescription } from '@/lib/mcp/workflow-tool-schema' +import { executeWorkflowService } from '@/lib/workflows/executor/execute-service' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkflowMcpServeAPI') @@ -281,21 +276,6 @@ async function getDuplicateToolName(serverId: string): Promise { return duplicate?.toolName ?? null } -async function readWorkflowExecutionResult( - response: Response, - signal: AbortSignal -): Promise { - const text = await readResponseTextWithLimit(response, { - maxBytes: MAX_MCP_WORKFLOW_RESPONSE_BYTES, - label: 'MCP workflow execution response', - signal, - }) - const parsed = parseJsonValue(text) - if (parsed.success) return parsed.value - if (!response.ok) return { error: response.statusText || 'Workflow execution failed' } - throw new Error('Invalid workflow execution response') -} - async function getServer(serverId: string) { const [server] = await db .select({ @@ -809,83 +789,113 @@ async function handleToolsCall( wf.workspaceId ) - const executeUrl = `${getInternalApiBaseUrl()}/api/workflows/${tool.workflowId}/execute` - const headers: Record = { - 'Content-Type': 'application/json', - [BILLING_ATTRIBUTION_HEADER]: serializeBillingAttributionHeader(billingAttribution), - [MCP_TOOL_BRIDGE_HEADER]: 'true', - } - const abortedBeforeExecute = callerAbortedJsonRpcResponse(id, abortSignal) if (abortedBeforeExecute) return abortedBeforeExecute - const internalToken = await generateInternalToken(actorUserId) - headers.Authorization = `Bearer ${internalToken}` - if (executeAuthContext?.useAuthenticatedUserAsActor) { - headers[MCP_TOOL_BRIDGE_ACTOR_HEADER] = 'authenticated-user' - } - - if (simViaHeader) { - headers[SIM_VIA_HEADER] = simViaHeader - } - - logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name}`) + logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name} (in-process)`) - const workflowRequestBody = JSON.stringify({ - input: params.arguments || {}, - triggerType: 'mcp', - includeFileBase64: false, - ...(wf.deploymentVersionId ? { deploymentVersionId: wf.deploymentVersionId } : {}), - }) + const workflowInput = params.arguments || {} assertKnownSizeWithinLimit( - Buffer.byteLength(workflowRequestBody, 'utf-8'), + Buffer.byteLength(JSON.stringify(workflowInput), 'utf-8'), MAX_MCP_WORKFLOW_REQUEST_BYTES, 'MCP workflow execution request body' ) - const response = await fetch(executeUrl, { - method: 'POST', - headers, - body: workflowRequestBody, - signal: abortSignal.signal, - }) - const executeResult = await readWorkflowExecutionResult(response, abortSignal.signal) - const executeResultObject = isJsonObject(executeResult) ? executeResult : null + /** + * In-process execution replaces the historical HTTP hop to the execute + * endpoint: the bridge's special needs — deployment-version pinning, MCP + * response-size rejection, actor override — are typed options instead of + * header sniffing, and billing attribution is passed as the immutable + * upstream snapshot exactly as the header carried it. + */ + const serviceResult = await executeWorkflowService({ + workflowId: tool.workflowId, + userId: actorUserId, + input: workflowInput, + triggerType: 'mcp', + requestId: generateRequestId(), + useAuthenticatedUserAsActor: executeAuthContext?.useAuthenticatedUserAsActor ?? false, + upstreamBillingAttribution: billingAttribution, + deploymentVersionId: wf.deploymentVersionId, + includeFileBase64: false, + rejectLargeInlineOutput: true, + callChain: simViaHeader ? parseCallChain(simViaHeader) : undefined, + abortSignal: abortSignal.signal, + }) - if (!response.ok) { - const errorMessage = - typeof executeResultObject?.error === 'string' - ? executeResultObject.error - : 'Workflow execution failed' - const status = getWorkflowErrorStatus(response.status) + if (!serviceResult.ok) { + const failure = serviceResult.failure + const status = getWorkflowErrorStatus(failure.statusCode) const responseHeaders: Record = {} - const retryAfter = response.headers.get('retry-after') - if (retryAfter) responseHeaders['Retry-After'] = retryAfter + if (failure.retryAfterMs !== undefined) { + responseHeaders['Retry-After'] = Math.max( + 1, + Math.ceil(failure.retryAfterMs / 1000) + ).toString() + } return NextResponse.json( createError( id, - getWorkflowErrorCode(response.status, executeResultObject ?? {}), - errorMessage, + getWorkflowErrorCode(failure.statusCode, { code: failure.code }), + failure.message, { - httpStatus: response.status, - retryable: [408, 429, 503].includes(response.status), - code: - typeof executeResultObject?.code === 'string' ? executeResultObject.code : undefined, + httpStatus: failure.statusCode, + retryable: [408, 429, 503].includes(failure.statusCode), + code: failure.code, + ...(failure.executionId ? { executionId: failure.executionId } : {}), } ), { status, headers: responseHeaders } ) } - const toolOutput = - executeResultObject?.success === false - ? executeResult - : executeResultObject && hasResponseField(executeResultObject, 'output') - ? executeResultObject.output - : executeResult + if ('queued' in serviceResult || 'stream' in serviceResult) { + // The bridge never requests async or stream modes. + throw new Error('Unexpected execution mode result for MCP tool call') + } + + if (serviceResult.aborted === 'client') { + return NextResponse.json( + createError(id, ErrorCode.ConnectionClosed, 'Client cancelled request', { + httpStatus: 499, + retryable: false, + executionId: serviceResult.executionId, + }), + { status: 499 } + ) + } + + if (serviceResult.aborted === 'timeout') { + return NextResponse.json( + createError( + id, + ErrorCode.InternalError, + serviceResult.error?.message ?? 'Execution timed out', + { + httpStatus: 408, + retryable: true, + code: 'TIMEOUT', + executionId: serviceResult.executionId, + } + ), + { status: 408 } + ) + } + + const isError = serviceResult.status !== 'completed' + const toolOutput = isError + ? { + success: false, + executionId: serviceResult.executionId, + output: serviceResult.output ?? {}, + // Structured error: parents/clients route on `code` and hand the + // provider the executionId to reproduce the failure. + error: serviceResult.error, + } + : (serviceResult.output ?? {}) const result: CallToolResult = { content: [{ type: 'text', text: serializeToolText(toolOutput) }], - isError: executeResultObject?.success === false, + isError, } return createJsonRpcResponseWithLimit( diff --git a/apps/sim/app/api/skills/route.ts b/apps/sim/app/api/skills/route.ts index f31f0881e71..251635a37fb 100644 --- a/apps/sim/app/api/skills/route.ts +++ b/apps/sim/app/api/skills/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { @@ -10,10 +9,14 @@ import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { checkSkillsUpdateAccess, getSkillActorContext } from '@/lib/skills/access' +import { + performCreateSkill, + performDeleteSkill, + performUpdateSkill, + statusForSkillOrchestrationError, +} from '@/lib/skills/orchestration' import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' -import { deleteSkill, listSkillsForUser, upsertSkills } from '@/lib/workflows/skills/operations' +import { listSkillsForUser } from '@/lib/workflows/skills/operations' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('SkillsAPI') @@ -92,84 +95,75 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - if (skills.some((s) => s.id && isBuiltinSkillId(s.id))) { - return NextResponse.json({ error: 'Built-in skills are read-only' }, { status: 400 }) + /** + * Each item is applied through the skill orchestration, which owns the + * built-in guard, the field limits, the per-skill editor check, and the + * audit. Creating still requires workspace write; editing an existing skill + * is gated per skill inside `performUpdateSkill`. + * + * The batch is applied item by item rather than in one transaction: this + * endpoint's callers submit a single skill, and one shared authority for the + * rules is worth more than atomicity across a batch nobody sends. + */ + const actor = { + actorName: authResult.userName, + actorEmail: authResult.userEmail, + source, + request: req, } - // Updating an existing skill requires editor access (explicit editor row - // or derived workspace admin); creating a new one requires workspace write. - const requestedIds = skills.flatMap((s) => (s.id ? [s.id] : [])) - const { existingIds, denied } = await checkSkillsUpdateAccess({ - workspaceId, - userId, - skillIds: requestedIds, - workspaceAccess, - }) - - if (denied.length > 0) { - logger.warn(`[${requestId}] User ${userId} is not an editor of skills being updated`, { - deniedSkillIds: denied.map((s) => s.id), - }) - return NextResponse.json( - { - error: `Skill editor access required to update: ${denied.map((s) => s.name).join(', ')}`, - }, - { status: 403 } - ) - } + for (const item of skills) { + if (item.id) { + const result = await performUpdateSkill({ + workspaceId, + userId, + skillId: item.id, + name: item.name, + description: item.description, + content: item.content, + ...actor, + }) + if (!result.success) { + logger.warn(`[${requestId}] Skill update rejected`, { + skillId: item.id, + errorCode: result.errorCode, + }) + return NextResponse.json( + { error: result.error ?? 'Failed to update skill' }, + { status: statusForSkillOrchestrationError(result.errorCode) } + ) + } + continue + } - const hasCreates = skills.some((s) => !s.id || !existingIds.has(s.id)) - if (hasCreates && !workspaceAccess.canWrite) { - logger.warn( - `[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) - } + if (!workspaceAccess.canWrite) { + logger.warn( + `[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}` + ) + return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) + } - try { - const { touched } = await upsertSkills({ - skills, + const result = await performCreateSkill({ workspaceId, userId, - requestId, - returnSkills: false, + name: item.name!, + description: item.description!, + content: item.content!, + ...actor, }) - - for (const { id, name, operation } of touched) { - const isUpdate = operation === 'updated' - recordAudit({ - workspaceId, - actorId: userId, - actorName: authResult.userName ?? undefined, - actorEmail: authResult.userEmail ?? undefined, - action: isUpdate ? AuditAction.SKILL_UPDATED : AuditAction.SKILL_CREATED, - resourceType: AuditResourceType.SKILL, - resourceId: id, - resourceName: name, - description: `${isUpdate ? 'Updated' : 'Created'} skill "${name}"`, - metadata: { source }, - }) - captureServerEvent( - userId, - isUpdate ? 'skill_updated' : 'skill_created', - { skill_id: id, skill_name: name, workspace_id: workspaceId, source }, - { groups: { workspace: workspaceId } } + if (!result.success) { + logger.warn(`[${requestId}] Skill create rejected`, { errorCode: result.errorCode }) + return NextResponse.json( + { error: result.error ?? 'Failed to create skill' }, + { status: statusForSkillOrchestrationError(result.errorCode) } ) } + } - const resultSkills = await listSkillsForUser({ workspaceId, userId, workspaceAccess }) - const data = resultSkills.map((s) => ({ ...s, readOnly: isBuiltinSkillId(s.id) })) + const resultSkills = await listSkillsForUser({ workspaceId, userId, workspaceAccess }) + const data = resultSkills.map((s) => ({ ...s, readOnly: isBuiltinSkillId(s.id) })) - return NextResponse.json({ success: true, data }) - } catch (upsertError) { - if (upsertError instanceof Error && upsertError.message.includes('is unavailable')) { - return NextResponse.json({ error: upsertError.message }, { status: 409 }) - } - if (upsertError instanceof Error && upsertError.message.startsWith('Skill not found')) { - return NextResponse.json({ error: 'Skill not found' }, { status: 404 }) - } - throw upsertError - } + return NextResponse.json({ success: true, data }) } catch (error) { logger.error(`[${requestId}] Error updating skills`, error) return NextResponse.json({ error: 'Failed to update skills' }, { status: 500 }) @@ -200,42 +194,25 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { } const { id: skillId, workspaceId, source } = query.data - if (!isBuiltinSkillId(skillId)) { - const actor = await getSkillActorContext(skillId, userId) - if (!actor.skill || actor.skill.workspaceId !== workspaceId || !actor.hasWorkspaceAccess) { - logger.warn(`[${requestId}] Skill not found: ${skillId}`) - return NextResponse.json({ error: 'Skill not found' }, { status: 404 }) - } - if (!actor.canEdit) { - logger.warn(`[${requestId}] User ${userId} is not an editor of skill ${skillId}`) - return NextResponse.json({ error: 'Skill editor access required' }, { status: 403 }) - } - } - - const deleted = await deleteSkill({ skillId, workspaceId }) - if (!deleted) { - logger.warn(`[${requestId}] Skill not found: ${skillId}`) - return NextResponse.json({ error: 'Skill not found' }, { status: 404 }) - } - - recordAudit({ + const result = await performDeleteSkill({ workspaceId, - actorId: authResult.userId, - actorName: authResult.userName ?? undefined, - actorEmail: authResult.userEmail ?? undefined, - action: AuditAction.SKILL_DELETED, - resourceType: AuditResourceType.SKILL, - resourceId: skillId, - description: `Deleted skill`, - metadata: { source }, - }) - - captureServerEvent( userId, - 'skill_deleted', - { skill_id: skillId, workspace_id: workspaceId, source }, - { groups: { workspace: workspaceId } } - ) + skillId, + actorName: authResult.userName, + actorEmail: authResult.userEmail, + source, + request, + }) + if (!result.success) { + logger.warn(`[${requestId}] Skill delete rejected`, { + skillId, + errorCode: result.errorCode, + }) + return NextResponse.json( + { error: result.error ?? 'Failed to delete skill' }, + { status: statusForSkillOrchestrationError(result.errorCode) } + ) + } logger.info(`[${requestId}] Deleted skill: ${skillId}`) return NextResponse.json({ success: true }) diff --git a/apps/sim/app/api/table/[tableId]/columns/route.test.ts b/apps/sim/app/api/table/[tableId]/columns/route.test.ts index 4ac282861cd..e8497a0fa04 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.test.ts @@ -42,6 +42,13 @@ vi.mock('@/lib/table', () => ({ updateColumnOptions: mockUpdateColumnOptions, updateColumnType: mockUpdateColumnType, })) +vi.mock('@/lib/table/columns/service', () => ({ + renameColumn: mockRenameColumn, + updateColumnConstraints: mockUpdateColumnConstraints, + updateColumnCurrency: mockUpdateColumnCurrency, + updateColumnOptions: mockUpdateColumnOptions, + updateColumnType: mockUpdateColumnType, +})) vi.mock('@/app/api/table/utils', () => ({ accessError: () => new Response('denied', { status: 403 }), checkAccess: mockCheckAccess, @@ -50,6 +57,7 @@ vi.mock('@/app/api/table/utils', () => ({ tableLockErrorResponse: () => null, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { PATCH } from '@/app/api/table/[tableId]/columns/route' const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' @@ -159,7 +167,10 @@ describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => { // Stands in for the race the guards cannot close: the column stopped being // a currency between the snapshot the guards read and this write. mockUpdateColumnCurrency.mockRejectedValue( - new Error('Cannot set currency on column "amount" of type "string"') + new OrchestrationError( + 'validation', + 'Cannot set currency on column "amount" of type "string"' + ) ) const response = await patch({ name: 'renamed', currencyCode: 'USD' }) diff --git a/apps/sim/app/api/table/[tableId]/columns/route.ts b/apps/sim/app/api/table/[tableId]/columns/route.ts index abda376ede7..d899372d2be 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.ts @@ -8,21 +8,12 @@ import { import { parseRequest } from '@/lib/api/server' import { isZodError, validationErrorResponse } from '@/lib/api/server/validation' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - addTableColumn, - deleteColumn, - renameColumn, - updateColumnConstraints, - updateColumnCurrency, - updateColumnOptions, - updateColumnType, -} from '@/lib/table' -import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys' -import { columnTypeById } from '@/lib/table/column-types' -import { isSupportedCurrencyCode } from '@/lib/table/currency' +import { addTableColumn, deleteColumn } from '@/lib/table' import { signalTableSchemaChanged } from '@/lib/table/events' +import { performUpdateTableColumn } from '@/lib/table/orchestration' import { accessError, checkAccess, @@ -122,216 +113,35 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - const { updates } = validated - let updatedTable = null - - // A payload that repeats the current type must not go through - // `updateColumnType` — it early-returns on an unchanged type and would drop - // any `options` alongside it. Only a real type change routes there; an - // unchanged type with options routes to the options-only update. - const currentColumn = table.schema.columns.find((c) => - columnMatchesRef(c, validated.columnName) - ) - // Address every write below by the stable id, not the name: a rename folded - // into one of them must not break the next one's lookup. - const columnRef = currentColumn ? getColumnId(currentColumn) : validated.columnName - // The constraints write below is a separate, unconditional step, so it is - // the last one whenever it runs — that is the write the rename rides on. - const typeChanging = updates.type !== undefined && updates.type !== currentColumn?.type - if (!currentColumn) { - return NextResponse.json( - { error: `Column "${validated.columnName}" not found` }, - { status: 404 } - ) - } - - // A retype applies and validates the constraints itself, so the separate - // constraint write only runs when the type is unchanged. The rename rides - // whichever write actually runs last. - const typedWriteRuns = - typeChanging || - updates.currencyCode !== undefined || - updates.options !== undefined || - updates.multiple !== undefined - const constraintsWriteRuns = - !typedWriteRuns && (updates.required !== undefined || updates.unique !== undefined) - const renameWithTypedWrite = - updates.name && !constraintsWriteRuns ? { newName: updates.name } : {} - - // Every write below is its own locked transaction, so one that is going to - // fail leaves the earlier ones committed. These guards reject the knowable - // cases up front, before any write at all. - // Gate on the type the column ENDS UP with, not on whether the type is - // changing: an options-only update on an existing select column carries the - // same hazard as a conversion does. - const resultingType = updates.type ?? currentColumn?.type - if (updates.currencyCode !== undefined) { - if (resultingType !== 'currency') { - return NextResponse.json( - { - error: `Cannot set currency on column "${validated.columnName}" of type "${resultingType}"`, - }, - { status: 400 } - ) - } - if (!isSupportedCurrencyCode(updates.currencyCode)) { - return NextResponse.json( - { - error: `Invalid currency code "${updates.currencyCode}". Use an ISO 4217 code, e.g. USD`, - }, - { status: 400 } - ) - } - } - // The rename runs last (see below), so a name already taken would fail after - // the typed write committed. This is the only rename failure a caller can - // cause; catching it here leaves just the concurrent-collision race, which - // no pre-flight check can close. - if ( - updates.name && - table.schema.columns.some( - (c) => - c.name.toLowerCase() === updates.name?.toLowerCase() && - !columnMatchesRef(c, validated.columnName) - ) - ) { - return NextResponse.json( - { error: `Column "${updates.name}" already exists` }, - { status: 400 } - ) - } - if ( - currentColumn?.workflowGroupId && - (updates.required !== undefined || updates.unique !== undefined) - ) { - return NextResponse.json( - { - error: `Cannot change constraints on workflow-output column "${currentColumn.name}". Constraints aren't applicable to columns whose values come from workflow execution.`, - }, - { status: 400 } - ) - } - if (updates.unique === true && !columnTypeById(resultingType).supportsUnique) { + const outcome = await performUpdateTableColumn({ + table, + columnName: validated.columnName, + userId: authResult.userId, + updates: validated.updates, + requestId, + request, + }) + if (!outcome.success || !outcome.table) { return NextResponse.json( - { error: `Cannot set a ${resultingType} column as unique` }, - { status: 400 } - ) - } - - if (typeChanging) { - updatedTable = await updateColumnType( - { - tableId, - columnName: columnRef, - newType: updates.type as NonNullable, - ...(updates.options !== undefined ? { options: updates.options } : {}), - ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), - ...(updates.currencyCode !== undefined ? { currencyCode: updates.currencyCode } : {}), - // Forwarded so the conversion validates against the constraint this - // same request is about to set, not the column's current one. - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - ...renameWithTypedWrite, - }, - requestId - ) - } else if (updates.currencyCode !== undefined) { - // Re-denominating an existing currency column: schema-only, no cell - // rewrite. Reached only when the type is unchanged — a conversion INTO - // currency carries the code through `updateColumnType` above. - updatedTable = await updateColumnCurrency( - { - tableId, - columnName: columnRef, - currencyCode: updates.currencyCode, - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - ...renameWithTypedWrite, - }, - requestId - ) - } else if (updates.options !== undefined || updates.multiple !== undefined) { - updatedTable = await updateColumnOptions( - { - tableId, - columnName: columnRef, - options: updates.options ?? currentColumn?.options ?? [], - ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), - // Forwarded so the removal guard validates against the constraint this - // same request is about to set, not the column's current one. - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - ...renameWithTypedWrite, - }, - requestId - ) - } - - // Skipped whenever a typed write ran: that write already applied and - // validated these, in one transaction with the change they accompany. - if (constraintsWriteRuns) { - updatedTable = await updateColumnConstraints( - { - tableId, - columnName: columnRef, - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - ...(updates.name ? { newName: updates.name } : {}), - }, - requestId + { error: outcome.error ?? 'Failed to update column' }, + { status: statusForOrchestrationError(outcome.errorCode) } ) } - // A rename rides along with the LAST write above, inside that write's - // transaction — a rename is metadata-only (rows key on the stable column - // id), so nothing forces it to be its own write, and folding it in is what - // stops a combined request from committing one half and then failing. Only - // a rename with nothing to ride on runs standalone. - if (updates.name && !updatedTable) { - updatedTable = await renameColumn( - { tableId, oldName: columnRef, newName: updates.name }, - requestId - ) - } - - if (!updatedTable) { - return NextResponse.json({ error: 'No updates specified' }, { status: 400 }) - } + // Live-collab: tell open viewers the change landed so they refetch. signalTableSchemaChanged(tableId) return NextResponse.json({ success: true, data: { - columns: updatedTable.schema.columns.map(normalizeColumn), + columns: outcome.table.schema.columns.map(normalizeColumn), }, }) } catch (error) { - const lockError = tableLockErrorResponse(error) - if (lockError) return lockError if (isZodError(error)) { return validationErrorResponse(error, 'Invalid request data') } - const msg = rootErrorMessage(error) - if (msg.includes('not found') || msg.includes('Table not found')) { - return NextResponse.json({ error: msg }, { status: 404 }) - } - if ( - msg.includes('already exists') || - msg.includes('Cannot delete the last column') || - msg.includes('Cannot set column') || - msg.includes('Cannot set unique column') || - msg.includes('Invalid column') || - msg.includes('exceeds maximum') || - msg.includes('incompatible') || - msg.includes('duplicate') || - msg.includes('option') || - msg.includes('currency') || - msg.includes('is already type') - ) { - return NextResponse.json({ error: msg }, { status: 400 }) - } - logger.error(`[${requestId}] Error updating column in table ${tableId}:`, error) return NextResponse.json({ error: 'Failed to update column' }, { status: 500 }) } diff --git a/apps/sim/app/api/table/[tableId]/columns/run/route.ts b/apps/sim/app/api/table/[tableId]/columns/run/route.ts index e1927a204c1..e9140d22a83 100644 --- a/apps/sim/app/api/table/[tableId]/columns/run/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/run/route.ts @@ -9,7 +9,12 @@ import { TableQueryValidationError } from '@/lib/table/errors' import { signalTableRowsChanged } from '@/lib/table/events' import { toLegacyFilter } from '@/lib/table/query-builder/converters' import { runWorkflowColumn } from '@/lib/table/workflow-columns' -import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils' +import { + accessError, + checkAccess, + orchestrationErrorResponse, + tableFilterError, +} from '@/app/api/table/utils' const logger = createLogger('TableRunColumnAPI') @@ -74,9 +79,8 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro if (error instanceof TableQueryValidationError) { return NextResponse.json({ error: error.message }, { status: 400 }) } - if (error instanceof Error && error.message === 'Invalid workspace ID') { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified logger.error(`run-column failed:`, error) return NextResponse.json({ error: 'Failed to run columns' }, { status: 500 }) } diff --git a/apps/sim/app/api/table/[tableId]/import/route.test.ts b/apps/sim/app/api/table/[tableId]/import/route.test.ts index baf8c313a4f..a2689295725 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.test.ts @@ -79,6 +79,7 @@ vi.mock('@/lib/table/billing', () => ({ limit >= 0 && current + added > limit, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { TableLockedError } from '@/lib/table/mutation-locks' import { POST } from '@/app/api/table/[tableId]/import/route' @@ -372,7 +373,10 @@ describe('POST /api/table/[tableId]/import', () => { it('surfaces unique violations from importAppendRows as 400', async () => { mockImportAppendRows.mockRejectedValueOnce( - new Error('Row 1: Column "name" must be unique. Value "Alice" already exists in row row_xxx') + new OrchestrationError( + 'validation', + 'Row 1: Column "name" must be unique. Value "Alice" already exists in row row_xxx' + ) ) const response = await callPost( createFormData(createCsvFile('name,age\nAlice,30'), { mode: 'append' }) @@ -516,7 +520,9 @@ describe('POST /api/table/[tableId]/import', () => { }) it('surfaces column-creation failures from importAppendRows as 400', async () => { - mockImportAppendRows.mockRejectedValueOnce(new Error('Column "email" already exists')) + mockImportAppendRows.mockRejectedValueOnce( + new OrchestrationError('validation', 'Column "email" already exists') + ) const response = await callPost( createFormData(createCsvFile('name,age,email\nAlice,30,a@x.io'), { mode: 'append', @@ -529,7 +535,9 @@ describe('POST /api/table/[tableId]/import', () => { }) it('surfaces row insert failures without success when schema was mutated', async () => { - mockImportAppendRows.mockRejectedValueOnce(new Error('must be unique')) + mockImportAppendRows.mockRejectedValueOnce( + new OrchestrationError('validation', 'must be unique') + ) const response = await callPost( createFormData(createCsvFile('name,age,email\nAlice,30,a@x.io'), { mode: 'append', diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index 8ee9ed8f170..3aa28fe34ee 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' @@ -351,21 +352,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 } ) } } @@ -403,17 +396,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 } @@ -422,17 +410,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 225e7556613..12f40106575 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -5,21 +5,19 @@ import { getTableQuerySchema, updateTableContract } from '@/lib/api/contracts/ta import { isZodError, parseRequest, validationErrorResponse } from '@/lib/api/server/validation' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { findActiveFolder } from '@/lib/folders/queries' -import { captureServerEvent } from '@/lib/posthog/server' -import { - deleteTable, - getTableById, - moveTableToFolder, - renameTable, - TableConflictError, - type TableSchema, - updateTableLocks, -} from '@/lib/table' +import { getTableById, TableConflictError, type TableSchema } from '@/lib/table' import { getWorkspaceTableLimits } from '@/lib/table/billing' import { signalTableSchemaChanged } from '@/lib/table/events' +import { + performDeleteTable, + performMoveTableToFolder, + performRenameTable, + performUpdateTableLocks, +} from '@/lib/table/orchestration' import { TABLE_LOCK_FLAGS, TABLE_LOCK_KINDS } from '@/lib/table/types' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' import { @@ -181,13 +179,35 @@ export const PATCH = withRouteHandler( { status: 403 } ) } - await updateTableLocks(tableId, validated.locks, authResult.userId, requestId, request) + const lockOutcome = await performUpdateTableLocks({ + tableId, + partial: validated.locks, + userId: authResult.userId, + requestId, + request, + }) + if (!lockOutcome.success) { + return NextResponse.json( + { error: lockOutcome.error ?? 'Failed to update table locks' }, + { status: statusForOrchestrationError(lockOutcome.errorCode) } + ) + } } if (validated.name !== undefined) { - await renameTable(tableId, validated.name, requestId, authResult.userId) - // Live-collab: tell open viewers the definition changed so they refetch. - signalTableSchemaChanged(tableId) + const renameOutcome = await performRenameTable({ + table, + newName: validated.name, + userId: authResult.userId, + requestId, + request, + }) + if (!renameOutcome.success) { + return NextResponse.json( + { error: renameOutcome.error ?? 'Failed to rename table' }, + { status: statusForOrchestrationError(renameOutcome.errorCode) } + ) + } } if (validated.folderId !== undefined) { @@ -199,24 +219,28 @@ export const PATCH = withRouteHandler( ) { return NextResponse.json({ error: 'Folder not found in this workspace' }, { status: 404 }) } - try { - await moveTableToFolder( - tableId, - table.workspaceId, - validated.folderId, - requestId, - authResult.userId + // The move re-asserts workspace and active state, so a miss means the table was + // archived between `checkAccess` and the write. That is a 404, not a server fault. + const moveOutcome = await performMoveTableToFolder({ + table, + folderId: validated.folderId, + userId: authResult.userId, + requestId, + request, + }) + if (!moveOutcome.success) { + return NextResponse.json( + { + error: moveOutcome.errorCode === 'not_found' ? 'Table not found' : moveOutcome.error, + }, + { status: statusForOrchestrationError(moveOutcome.errorCode) } ) - } catch (moveError) { - // The move re-asserts workspace and active state, so a miss means the table was - // archived between `checkAccess` and the write. That is a 404, not a server fault. - if (moveError instanceof Error && moveError.message.endsWith('not found')) { - return NextResponse.json({ error: 'Table not found' }, { status: 404 }) - } - throw moveError } } + // Live-collab: tell open viewers the definition changed so they refetch. + signalTableSchemaChanged(tableId) + // Re-read so the response reflects both a rename and a lock change. const updated = await getTableById(tableId) if (!updated) { @@ -271,14 +295,18 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - await deleteTable(tableId, requestId, authResult.userId) - - captureServerEvent( - authResult.userId, - 'table_deleted', - { table_id: tableId, workspace_id: table.workspaceId }, - { groups: { workspace: table.workspaceId } } - ) + const outcome = await performDeleteTable({ + table, + userId: authResult.userId, + requestId, + request, + }) + if (!outcome.success) { + return NextResponse.json( + { error: outcome.error ?? 'Failed to delete table' }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts index 6cb994bff85..88776a69a20 100644 --- a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts @@ -1,7 +1,6 @@ import { db } from '@sim/db' import { userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { @@ -11,16 +10,18 @@ import { } from '@/lib/api/contracts/tables' import { isZodError, parseRequest, validationErrorResponse } from '@/lib/api/server/validation' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RowData, TableSchema } from '@/lib/table' -import { deleteRow, updateRow } from '@/lib/table' +import { updateRow } from '@/lib/table' import { signalTableRowsChanged } from '@/lib/table/events' +import { performDeleteTableRow } from '@/lib/table/orchestration' import { rowWireTranslators } from '@/app/api/table/row-wire' import { accessError, checkAccess, - rootErrorMessage, + orchestrationErrorResponse, rowWriteErrorResponse, tableLockErrorResponse, } from '@/app/api/table/utils' @@ -145,12 +146,12 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR table, requestId ) + + // Live-collab: tell open viewers the change landed so they refetch. + signalTableRowsChanged(tableId) // Only `null` when a `cancellationGuard` is supplied and the SQL guard // rejects the write — this route doesn't pass one, so reaching null is a bug. if (!updatedRow) throw new Error('updateRow returned null without a cancellationGuard') - // An edit that also triggers a dispatch already emits dispatch/cell events; the - // debounced rows refetch on the peer coalesces the two. - signalTableRowsChanged(tableId) // Auto-dispatch for user edits is handled inside `updateRow` (mode: 'new'). // Firing a second mode: 'incomplete' dispatch here would race with the // `mode: 'new'` one AND bulk-clear sibling-group outputs (the incomplete @@ -177,10 +178,6 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR }, }) } catch (error) { - if (rootErrorMessage(error) === 'Row not found') { - return NextResponse.json({ error: 'Row not found' }, { status: 404 }) - } - const response = rowWriteErrorResponse(error) if (response) return response @@ -216,7 +213,15 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - await deleteRow(table, rowId, requestId) + const outcome = await performDeleteTableRow({ table, rowId, requestId }) + if (!outcome.success) { + return NextResponse.json( + { error: outcome.error ?? 'Failed to delete row' }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } + + // Live-collab: tell open viewers the change landed so they refetch. signalTableRowsChanged(tableId) return NextResponse.json({ @@ -230,11 +235,8 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row const lockError = tableLockErrorResponse(error) if (lockError) return lockError - const errorMessage = toError(error).message - - if (errorMessage === 'Row not found') { - return NextResponse.json({ error: errorMessage }, { status: 404 }) - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified logger.error(`[${requestId}] Error deleting row:`, error) return NextResponse.json({ error: 'Failed to delete row' }, { status: 500 }) diff --git a/apps/sim/app/api/table/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/users/me/usage-limits/route.ts b/apps/sim/app/api/users/me/usage-limits/route.ts index 8f2b18d024e..b5ef4610fb8 100644 --- a/apps/sim/app/api/users/me/usage-limits/route.ts +++ b/apps/sim/app/api/users/me/usage-limits/route.ts @@ -2,11 +2,10 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { usageLimitsRequestSchema } from '@/lib/api/contracts/usage-limits' -import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid' +import { checkHybridAuth } from '@/lib/auth/hybrid' import { checkServerSideUsageLimits } from '@/lib/billing' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { getUserStorageLimit, getUserStorageUsage } from '@/lib/billing/storage' -import { RateLimiter } from '@/lib/core/rate-limiter' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createErrorResponse } from '@/app/api/workflows/utils' @@ -23,22 +22,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const authenticatedUserId = auth.userId const userSubscription = await getHighestPrioritySubscription(authenticatedUserId) - const rateLimiter = new RateLimiter() - const triggerType = auth.authType === AuthType.API_KEY ? 'api' : 'manual' - const [syncStatus, asyncStatus] = await Promise.all([ - rateLimiter.getRateLimitStatusWithSubscription( - authenticatedUserId, - userSubscription, - triggerType, - false - ), - rateLimiter.getRateLimitStatusWithSubscription( - authenticatedUserId, - userSubscription, - triggerType, - true - ), - ]) const [usageCheck, storageUsage, storageLimit] = await Promise.all([ checkServerSideUsageLimits(authenticatedUserId), @@ -52,23 +35,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true, - rateLimit: { - sync: { - isLimited: syncStatus.remaining === 0, - requestsPerMinute: syncStatus.requestsPerMinute, - maxBurst: syncStatus.maxBurst, - remaining: syncStatus.remaining, - resetAt: syncStatus.resetAt, - }, - async: { - isLimited: asyncStatus.remaining === 0, - requestsPerMinute: asyncStatus.requestsPerMinute, - maxBurst: asyncStatus.maxBurst, - remaining: asyncStatus.remaining, - resetAt: asyncStatus.resetAt, - }, - authType: triggerType, - }, usage: { currentPeriodCost, limit: usageCheck.limit, diff --git a/apps/sim/app/api/users/me/usage-logs/route.test.ts b/apps/sim/app/api/users/me/usage-logs/route.test.ts index a45c8a0ff92..32295c7f887 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.test.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.test.ts @@ -57,7 +57,7 @@ describe('GET /api/users/me/usage-logs', () => { source: 'workflow', workflowName: null, creditCost: 100, - dollarCost: 0.5, + hasCost: true, }, ]) expect(body.summary).toEqual({ diff --git a/apps/sim/app/api/users/me/usage-logs/route.ts b/apps/sim/app/api/users/me/usage-logs/route.ts index d976d188bc8..9abd381c48c 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.ts @@ -17,6 +17,7 @@ const logger = createLogger('UsageLogsAPI') /** * Lists the authenticated user's credit-consuming usage events (model, tool, * and fixed charges), converted to credits for display in Billing settings. + * Session-only — the API-key-facing equivalent is `GET /api/v2/billing/usage/logs`. */ export const GET = withRouteHandler(async (request: NextRequest) => { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -51,7 +52,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { source: log.source, workflowName: log.workflowName ?? null, creditCost: creditsByLogId[log.id] ?? 0, - dollarCost: log.cost, + hasCost: log.cost > 0, })) const bySourceCredits = Object.fromEntries( diff --git a/apps/sim/app/api/v1/admin/audit-logs/route.ts b/apps/sim/app/api/v1/admin/audit-logs/route.ts index 9610232d357..f3dbc231e69 100644 --- a/apps/sim/app/api/v1/admin/audit-logs/route.ts +++ b/apps/sim/app/api/v1/admin/audit-logs/route.ts @@ -31,21 +31,13 @@ import { internalErrorResponse, listResponse, } from '@/app/api/v1/admin/responses' -import { - type AdminAuditLog, - createPaginationMeta, - parsePaginationParams, - toAdminAuditLog, -} from '@/app/api/v1/admin/types' +import { type AdminAuditLog, createPaginationMeta, toAdminAuditLog } from '@/app/api/v1/admin/types' import { buildFilterConditions } from '@/app/api/v1/audit-logs/query' const logger = createLogger('AdminAuditLogsAPI') export const GET = withRouteHandler( withAdminAuth(async (request) => { - const url = new URL(request.url) - const { limit, offset } = parsePaginationParams(url) - const parsed = await parseRequest( v1AdminListAuditLogsContract, request, @@ -56,6 +48,7 @@ export const GET = withRouteHandler( try { const query = parsed.data.query + const { limit, offset } = query const conditions = buildFilterConditions({ action: query.action, resourceType: query.resourceType, diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts index 69b773accf5..18bc485fbe6 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts @@ -29,6 +29,7 @@ import { isBillingEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { + adminInvalidJsonResponse, adminValidationErrorResponse, badRequestResponse, internalErrorResponse, @@ -152,7 +153,7 @@ export const PATCH = withRouteHandler( { params: routeParams }, { validationErrorResponse: adminValidationErrorResponse, - invalidJson: 'throw', + invalidJsonResponse: adminInvalidJsonResponse, } ) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts index 68b79e3a78a..83234df0a7b 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts @@ -45,6 +45,7 @@ import { isBillingEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { + adminInvalidJsonResponse, adminValidationErrorResponse, badRequestResponse, internalErrorResponse, @@ -144,7 +145,7 @@ export const PATCH = withRouteHandler( { params: routeParams }, { validationErrorResponse: adminValidationErrorResponse, - invalidJson: 'throw', + invalidJsonResponse: adminInvalidJsonResponse, } ) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts b/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts index 5c9525ca7ff..e6c84765379 100644 --- a/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts +++ b/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts @@ -101,7 +101,10 @@ export const POST = withRouteHandler( }) } catch (error) { logger.error('Failed to requeue outbox event', { eventId: id, error: toError(error).message }) - return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + return NextResponse.json( + { success: false, error: 'Failed to requeue outbox event' }, + { status: 500 } + ) } }) ) diff --git a/apps/sim/app/api/v1/admin/outbox/route.ts b/apps/sim/app/api/v1/admin/outbox/route.ts index f88ac55536c..57ce53c49f5 100644 --- a/apps/sim/app/api/v1/admin/outbox/route.ts +++ b/apps/sim/app/api/v1/admin/outbox/route.ts @@ -77,7 +77,10 @@ export const GET = withRouteHandler( }) } catch (error) { logger.error('Failed to list outbox events', { error: toError(error).message }) - return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + return NextResponse.json( + { success: false, error: 'Failed to list outbox events' }, + { status: 500 } + ) } }) ) diff --git a/apps/sim/app/api/v1/admin/referral-campaigns/route.ts b/apps/sim/app/api/v1/admin/referral-campaigns/route.ts index b7f7c162118..1432b46d37b 100644 --- a/apps/sim/app/api/v1/admin/referral-campaigns/route.ts +++ b/apps/sim/app/api/v1/admin/referral-campaigns/route.ts @@ -41,6 +41,7 @@ import { requireStripeClient } from '@/lib/billing/stripe-client' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { + adminInvalidJsonResponse, adminValidationErrorResponse, badRequestResponse, internalErrorResponse, @@ -181,7 +182,7 @@ export const POST = withRouteHandler( {}, { validationErrorResponse: adminValidationErrorResponse, - invalidJson: 'throw', + invalidJsonResponse: adminInvalidJsonResponse, } ) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/v1/audit-logs/auth.ts b/apps/sim/app/api/v1/audit-logs/auth.ts index 566a17a5e27..60c3d61fc5c 100644 --- a/apps/sim/app/api/v1/audit-logs/auth.ts +++ b/apps/sim/app/api/v1/audit-logs/auth.ts @@ -26,10 +26,19 @@ type AuthResult = | { success: false; response: NextResponse } /** - * Validates enterprise audit log access for the given user. + * Structured enterprise audit-access result shared by the v1 and v2 surfaces so + * each version can render the failure in its own response envelope. + */ +export type EnterpriseAuditAccessResult = + | { success: true; context: EnterpriseAuditContext } + | { success: false; status: number; message: string } + +/** + * Core enterprise audit-access check (no response rendering). * * Checks: - * 1. User belongs to an organization + * 1. User belongs to an organization (the target one when + * `targetOrganizationId` is given) * 2. User has admin or owner role * 3. The organization is entitled to audit logs — an active enterprise * subscription when billing runs, otherwise the deployment's audit-logs @@ -39,13 +48,12 @@ type AuthResult = * there made audit logs unreachable on every self-hosted deployment, since no * subscription row is ever written without billing. * - * Returns the organization ID and all member user IDs on success, - * or an error response on failure. + * Returns the organization ID and all member user IDs on success. */ -export async function validateEnterpriseAuditAccess( +export async function resolveEnterpriseAuditAccess( userId: string, targetOrganizationId?: string -): Promise { +): Promise { const [membership] = await db .select({ organizationId: member.organizationId, role: member.role }) .from(member) @@ -57,43 +65,24 @@ export async function validateEnterpriseAuditAccess( .limit(1) if (!membership) { - return { - success: false, - response: NextResponse.json({ error: 'Not a member of any organization' }, { status: 403 }), - } + return { success: false, status: 403, message: 'Not a member of any organization' } } if (membership.role !== 'admin' && membership.role !== 'owner') { - return { - success: false, - response: NextResponse.json( - { error: 'Organization admin or owner role required' }, - { status: 403 } - ), - } + return { success: false, status: 403, message: 'Organization admin or owner role required' } } if (isBillingEnabled) { const billingBlocked = await isOrganizationBillingBlocked(membership.organizationId) if (billingBlocked) { - return { - success: false, - response: NextResponse.json( - { error: 'Active enterprise subscription required' }, - { status: 403 } - ), - } + return { success: false, status: 403, message: 'Active enterprise subscription required' } } } else if (!isAuditLogsEnabled) { return { success: false, - response: NextResponse.json( - { - error: - 'Audit logs are disabled. Set ENTERPRISE_ENABLED or AUDIT_LOGS_ENABLED to enable them.', - }, - { status: 403 } - ), + status: 403, + message: + 'Audit logs are disabled. Set ENTERPRISE_ENABLED or AUDIT_LOGS_ENABLED to enable them.', } } @@ -118,13 +107,7 @@ export async function validateEnterpriseAuditAccess( ]) if (isBillingEnabled && orgSub.length === 0) { - return { - success: false, - response: NextResponse.json( - { error: 'Active enterprise subscription required' }, - { status: 403 } - ), - } + return { success: false, status: 403, message: 'Active enterprise subscription required' } } const orgMemberIds = orgMembers.map((m) => m.userId) @@ -137,9 +120,22 @@ export async function validateEnterpriseAuditAccess( return { success: true, - context: { - organizationId: membership.organizationId, - orgMemberIds, - }, + context: { organizationId: membership.organizationId, orgMemberIds }, + } +} + +/** + * v1 wrapper: renders {@link resolveEnterpriseAuditAccess} as the v1 `{ error }` + * response body. + */ +export async function validateEnterpriseAuditAccess( + userId: string, + targetOrganizationId?: string +): Promise { + const result = await resolveEnterpriseAuditAccess(userId, targetOrganizationId) + if (result.success) return { success: true, context: result.context } + return { + success: false, + response: NextResponse.json({ error: result.message }, { status: result.status }), } } diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts index 94c4832f265..33ac4611ffe 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { document, knowledgeConnector } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' @@ -8,8 +7,12 @@ import { v1GetKnowledgeDocumentContract, } from '@/lib/api/contracts/v1/knowledge' import { parseRequest } from '@/lib/api/server' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { deleteDocument } from '@/lib/knowledge/documents/service' +import { performDeleteKnowledgeDocument } from '@/lib/knowledge/orchestration' import { handleError, resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' import { authenticateRequest, v1ValidationErrorResponse } from '@/app/api/v1/middleware' @@ -152,19 +155,24 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Document not found' }, { status: 404 }) } - await deleteDocument(documentId, requestId) - - recordAudit({ - workspaceId: parsed.data.query.workspaceId, - actorId: userId, - action: AuditAction.DOCUMENT_DELETED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: documentId, - resourceName: docs[0].filename, - description: `Deleted document "${docs[0].filename}" from knowledge base via API`, - metadata: { knowledgeBaseId }, + const outcome = await performDeleteKnowledgeDocument({ + knowledgeBase: { + id: knowledgeBaseId, + name: result.kb.name, + workspaceId: parsed.data.query.workspaceId, + }, + document: { id: documentId, filename: docs[0].filename }, + userId, + source: 'api', + requestId, request, }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to delete document') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts index 18898d704af..5cba10e6338 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts @@ -75,6 +75,9 @@ vi.mock('@/lib/uploads/contexts/workspace', () => ({ vi.mock('@/lib/uploads/utils/validation', () => ({ validateFileType: mockValidateFileType, + // Read at module scope by `lib/uploads/utils/file-utils`, which the route now + // reaches transitively through the knowledge orchestration module. + SUPPORTED_ARCHIVE_EXTENSIONS: [], })) vi.mock('@/lib/knowledge/documents/service', () => ({ diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts index dfd08d4c892..8f77bb467c6 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { type NextRequest, NextResponse } from 'next/server' import { v1ListKnowledgeDocumentsContract, @@ -10,19 +9,19 @@ import { resolveBillingAttribution, resolveSystemBillingAttribution, } from '@/lib/billing/core/billing-attribution' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { isPayloadSizeLimitError, MAX_MULTIPART_OVERHEAD_BYTES, readFormDataWithLimit, } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - createSingleDocument, - type DocumentData, - getDocuments, - processDocumentsWithQueue, -} from '@/lib/knowledge/documents/service' +import { getDocuments } from '@/lib/knowledge/documents/service' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' +import { performUploadKnowledgeDocument } from '@/lib/knowledge/orchestration' import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { validateFileType } from '@/lib/uploads/utils/validation' import { handleError, resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' @@ -189,47 +188,29 @@ export const POST = withRouteHandler( contentType ) - const newDocument = await createSingleDocument( - { + const outcome = await performUploadKnowledgeDocument({ + knowledgeBase: { id: knowledgeBaseId, name: result.kb.name, workspaceId }, + document: { filename: file.name, fileUrl: uploadedFile.url, fileSize: file.size, mimeType: contentType, }, - knowledgeBaseId, - requestId, - billingActorUserId - ) - - const documentData: DocumentData = { - documentId: newDocument.id, - filename: file.name, - fileUrl: uploadedFile.url, - fileSize: file.size, - mimeType: contentType, - } - - processDocumentsWithQueue( - [documentData], - knowledgeBaseId, - {}, + startProcessing: 'queue', + billingAttribution, + uploadedBy: billingActorUserId, + userId, + source: 'api', requestId, - billingAttribution - ).catch(() => { - // Processing errors are logged internally - }) - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.DOCUMENT_UPLOADED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: newDocument.id, - resourceName: file.name, - description: `Uploaded document "${file.name}" to knowledge base via API`, - metadata: { knowledgeBaseId, fileSize: file.size, mimeType: contentType }, request, }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to upload document') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } + const newDocument = outcome.document return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/v1/knowledge/[id]/route.ts b/apps/sim/app/api/v1/knowledge/[id]/route.ts index 8dbb280559f..373d0951636 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { type NextRequest, NextResponse } from 'next/server' import { v1DeleteKnowledgeBaseContract, @@ -6,8 +5,15 @@ import { v1UpdateKnowledgeBaseContract, } from '@/lib/api/contracts/v1/knowledge' import { parseRequest } from '@/lib/api/server' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { deleteKnowledgeBase, updateKnowledgeBase } from '@/lib/knowledge/service' +import { + performDeleteKnowledgeBase, + performUpdateKnowledgeBase, +} from '@/lib/knowledge/orchestration' import { formatKnowledgeBase, handleError, @@ -67,33 +73,26 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: Knowle const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, 'write') if (result instanceof NextResponse) return result - const updates: { - name?: string - description?: string - chunkingConfig?: { maxSize: number; minSize: number; overlap: number } - } = {} - if (name !== undefined) updates.name = name - if (description !== undefined) updates.description = description - if (chunkingConfig !== undefined) updates.chunkingConfig = chunkingConfig - - const updatedKb = await updateKnowledgeBase(id, updates, requestId) - - recordAudit({ + const outcome = await performUpdateKnowledgeBase({ + knowledgeBaseId: id, workspaceId, - actorId: userId, - action: AuditAction.KNOWLEDGE_BASE_UPDATED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: id, - resourceName: updatedKb.name, - description: `Updated knowledge base "${updatedKb.name}" via API`, - metadata: { updatedFields: Object.keys(updates) }, + userId, + source: 'api', + updates: { name, description, chunkingConfig }, + requestId, request, }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to update knowledge base') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } return NextResponse.json({ success: true, data: { - knowledgeBase: formatKnowledgeBase(updatedKb), + knowledgeBase: formatKnowledgeBase(outcome.knowledgeBase), message: 'Knowledge base updated successfully', }, }) @@ -125,18 +124,23 @@ export const DELETE = withRouteHandler( ) if (result instanceof NextResponse) return result - await deleteKnowledgeBase(id, requestId) - - recordAudit({ - workspaceId: parsed.data.query.workspaceId, - actorId: userId, - action: AuditAction.KNOWLEDGE_BASE_DELETED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: id, - resourceName: result.kb.name, - description: `Deleted knowledge base "${result.kb.name}" via API`, + const outcome = await performDeleteKnowledgeBase({ + knowledgeBase: { + id, + name: result.kb.name, + workspaceId: parsed.data.query.workspaceId, + }, + userId, + source: 'api', + requestId, request, }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to delete knowledge base') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/v1/knowledge/route.ts b/apps/sim/app/api/v1/knowledge/route.ts index 5b608484025..cacb36ed482 100644 --- a/apps/sim/app/api/v1/knowledge/route.ts +++ b/apps/sim/app/api/v1/knowledge/route.ts @@ -1,13 +1,16 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { type NextRequest, NextResponse } from 'next/server' import { v1CreateKnowledgeBaseContract, v1ListKnowledgeBasesContract, } from '@/lib/api/contracts/v1/knowledge' import { parseRequest } from '@/lib/api/server' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings' -import { createKnowledgeBase, getKnowledgeBases } from '@/lib/knowledge/service' +import { performCreateKnowledgeBase } from '@/lib/knowledge/orchestration' +import { getKnowledgeBases } from '@/lib/knowledge/service' import { formatKnowledgeBase, handleError } from '@/app/api/v1/knowledge/utils' import { authenticateRequest, @@ -76,35 +79,27 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (accessError) return accessError - const kb = await createKnowledgeBase( - { - name, - description, - workspaceId, - userId, - embeddingModel: getConfiguredEmbeddingModel(), - embeddingDimension: EMBEDDING_DIMENSIONS, - chunkingConfig: chunkingConfig ?? { maxSize: 1024, minSize: 100, overlap: 200 }, - }, - requestId - ) - - recordAudit({ + const outcome = await performCreateKnowledgeBase({ + userId, + source: 'api', workspaceId, - actorId: userId, - action: AuditAction.KNOWLEDGE_BASE_CREATED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: kb.id, - resourceName: kb.name, - description: `Created knowledge base "${kb.name}" via API`, - metadata: { chunkingConfig }, + name, + description, + chunkingConfig, + requestId, request, }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to create knowledge base') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } return NextResponse.json({ success: true, data: { - knowledgeBase: formatKnowledgeBase(kb), + knowledgeBase: formatKnowledgeBase(outcome.knowledgeBase), message: 'Knowledge base created successfully', }, }) diff --git a/apps/sim/app/api/v1/logs/filters.ts b/apps/sim/app/api/v1/logs/filters.ts index 70b89ae5824..ab540813893 100644 --- a/apps/sim/app/api/v1/logs/filters.ts +++ b/apps/sim/app/api/v1/logs/filters.ts @@ -1,5 +1,5 @@ import { workflow, workflowExecutionLogs } from '@sim/db/schema' -import { and, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm' +import { and, asc, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm' export interface LogFilters { workspaceId: string @@ -103,8 +103,14 @@ export function buildLogFilters(filters: LogFilters): SQL { return conditions.length > 0 ? and(...conditions)! : sql`true` } +/** + * Order rows by `(startedAt, id)` so the sort matches the keyset cursor's tuple + * comparison in {@link buildLogFilters}. Without the `id` tie-break, rows that + * share a `startedAt` have an arbitrary order and can be skipped or duplicated + * across pages. + */ export function getOrderBy(order: 'desc' | 'asc' = 'desc') { return order === 'desc' - ? desc(workflowExecutionLogs.startedAt) - : sql`${workflowExecutionLogs.startedAt} ASC` + ? [desc(workflowExecutionLogs.startedAt), desc(workflowExecutionLogs.id)] + : [asc(workflowExecutionLogs.startedAt), asc(workflowExecutionLogs.id)] } diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts index be40f9ae2dd..ee28ced7dac 100644 --- a/apps/sim/app/api/v1/logs/route.ts +++ b/apps/sim/app/api/v1/logs/route.ts @@ -118,7 +118,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const logs = await baseQuery .where(conditions) - .orderBy(orderBy) + .orderBy(...orderBy) .limit(params.limit + 1) const hasMore = logs.length > params.limit diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index bdc82a6613a..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' @@ -40,12 +42,25 @@ export type ApiEndpoint = | 'table-columns' | 'files' | 'file-detail' + | 'file-share' + | 'file-content' + | 'file-move' + | 'file-bulk-archive' | 'knowledge' | 'knowledge-detail' | 'knowledge-search' | 'copilot-chat' - | 'v2-tables' - | 'v2-table-rows' + | 'billing-usage' + | 'mcp-servers' + | 'mcp-server-detail' + | 'skills' + | 'skill-detail' + | 'custom-tools' + | 'custom-tool-detail' + | 'folders' + | 'folder-detail' + | 'credentials' + | 'credential-detail' export interface RateLimitResult { allowed: boolean @@ -192,42 +207,83 @@ export function createRateLimitResponse(result: RateLimitResult): NextResponse { } /** - * Verify that the API key is allowed to access the requested workspace. - * - * Enforces two policies: + * Structured workspace-access failure shared by the v1 and v2 API surfaces so + * each version can render the failure in its own response envelope. + */ +export interface WorkspaceAccessError { + status: number + code: 'FORBIDDEN' + message: string +} + +/** + * Core workspace-scope check (no response rendering). Enforces two policies: * - A workspace-scoped key may only target its own workspace. * - A personal key is rejected when the workspace has disabled personal API * keys (`allowPersonalApiKeys = false`), matching the workflow-execution * surface in `app/api/workflows/middleware.ts`. */ -export async function checkWorkspaceScope( +export async function resolveWorkspaceScope( rateLimit: RateLimitResult, requestedWorkspaceId: string -): Promise { +): Promise { if ( rateLimit.keyType === 'workspace' && rateLimit.workspaceId && rateLimit.workspaceId !== requestedWorkspaceId ) { - return NextResponse.json( - { error: 'API key is not authorized for this workspace' }, - { status: 403 } - ) + return { + status: 403, + code: 'FORBIDDEN', + message: 'API key is not authorized for this workspace', + } } if (rateLimit.keyType === 'personal') { const settings = await getWorkspaceBillingSettings(requestedWorkspaceId) if (!settings?.allowPersonalApiKeys) { - return NextResponse.json( - { error: 'Personal API keys are not allowed for this workspace' }, - { status: 403 } - ) + return { + status: 403, + code: 'FORBIDDEN', + message: 'Personal API keys are not allowed for this workspace', + } } } return null } +/** + * Core workspace-access check (scope + the user's workspace permission level), + * shared by v1 and v2. Returns a structured failure or null on success. + */ +export async function resolveWorkspaceAccess( + rateLimit: RateLimitResult, + userId: string, + workspaceId: string, + level: PermissionType = 'read' +): Promise { + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return scopeError + + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (!permissionSatisfies(permission, level)) { + return { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + } + return null +} + +/** + * v1 wrapper: renders {@link resolveWorkspaceScope} as the v1 `{ error }` body. + */ +export async function checkWorkspaceScope( + rateLimit: RateLimitResult, + requestedWorkspaceId: string +): Promise { + const failure = await resolveWorkspaceScope(rateLimit, requestedWorkspaceId) + return failure ? NextResponse.json({ error: failure.message }, { status: failure.status }) : null +} + /** * Resolves the usage actor for a workspace-scoped v1 request. Personal keys * identify their human owner; shared workspace keys use the billed account as @@ -244,7 +300,7 @@ export async function resolveWorkspaceRequestActor( } /** - * Validates workspace-scoped API key bounds and the user's workspace permission. + * v1 wrapper: renders {@link resolveWorkspaceAccess} as the v1 `{ error }` body. * Returns null on success, NextResponse on failure. */ export async function validateWorkspaceAccess( @@ -253,14 +309,8 @@ export async function validateWorkspaceAccess( workspaceId: string, level: PermissionType = 'read' ): Promise { - const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return scopeError - - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (!permissionSatisfies(permission, level)) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } - return null + const failure = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, level) + return failure ? NextResponse.json({ error: failure.message }, { status: failure.status }) : null } /** diff --git a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts index b5471842a6c..988c24825c9 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts @@ -7,25 +7,17 @@ import { v1UpdateTableColumnContract, } from '@/lib/api/contracts/v1/tables' import { parseRequest } from '@/lib/api/server' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - addTableColumn, - deleteColumn, - renameColumn, - updateColumnConstraints, - updateColumnCurrency, - updateColumnOptions, - updateColumnType, -} from '@/lib/table' -import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys' -import { columnTypeById } from '@/lib/table/column-types' -import { isSupportedCurrencyCode } from '@/lib/table/currency' +import { addTableColumn, deleteColumn } from '@/lib/table' import { signalTableSchemaChanged } from '@/lib/table/events' +import { performUpdateTableColumn } from '@/lib/table/orchestration' import { accessError, checkAccess, normalizeColumn, + orchestrationErrorResponse, tableLockErrorResponse, } from '@/app/api/table/utils' import { @@ -103,22 +95,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse - if (error instanceof Error) { - // Same caller-error set the internal columns route maps — an invalid - // select option set is a bad request, not a server fault. - if ( - error.message.includes('already exists') || - error.message.includes('maximum column') || - error.message.includes('Invalid column') || - error.message.includes('exceeds maximum') || - error.message.includes('option') - ) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - if (error.message === 'Table not found') { - return NextResponse.json({ error: error.message }, { status: 404 }) - } - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified logger.error(`[${requestId}] Error adding column to table:`, error) return NextResponse.json({ error: 'Failed to add column' }, { status: 500 }) @@ -156,228 +134,34 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - const { updates } = validated - let updatedTable = null - - // A payload that repeats the current type must not go through - // `updateColumnType` — it early-returns on an unchanged type and would drop - // any `options` alongside it. Only a real type change routes there; an - // unchanged type with options routes to the options-only update. - const currentColumn = table.schema.columns.find((c) => - columnMatchesRef(c, validated.columnName) - ) - // Address every write below by the stable id, not the name: a rename folded - // into one of them must not break the next one's lookup. - const columnRef = currentColumn ? getColumnId(currentColumn) : validated.columnName - // The constraints write below is a separate, unconditional step, so it is - // the last one whenever it runs — that is the write the rename rides on. - const typeChanging = updates.type !== undefined && updates.type !== currentColumn?.type - if (!currentColumn) { - return NextResponse.json( - { error: `Column "${validated.columnName}" not found` }, - { status: 404 } - ) - } - - // A retype applies and validates the constraints itself, so the separate - // constraint write only runs when the type is unchanged. The rename rides - // whichever write actually runs last. - const typedWriteRuns = - typeChanging || - updates.currencyCode !== undefined || - updates.options !== undefined || - updates.multiple !== undefined - const constraintsWriteRuns = - !typedWriteRuns && (updates.required !== undefined || updates.unique !== undefined) - const renameWithTypedWrite = - updates.name && !constraintsWriteRuns ? { newName: updates.name } : {} - - // Every write below is its own locked transaction, so one that is going to - // fail leaves the earlier ones committed. These guards reject the knowable - // cases up front, before any write at all. - // Gate on the type the column ENDS UP with, not on whether the type is - // changing: an options-only update on an existing select column carries the - // same hazard as a conversion does. - const resultingType = updates.type ?? currentColumn?.type - if (updates.currencyCode !== undefined) { - if (resultingType !== 'currency') { - return NextResponse.json( - { - error: `Cannot set currency on column "${validated.columnName}" of type "${resultingType}"`, - }, - { status: 400 } - ) - } - if (!isSupportedCurrencyCode(updates.currencyCode)) { - return NextResponse.json( - { - error: `Invalid currency code "${updates.currencyCode}". Use an ISO 4217 code, e.g. USD`, - }, - { status: 400 } - ) - } - } - // The rename runs last (see below), so a name already taken would fail after - // the typed write committed. This is the only rename failure a caller can - // cause; catching it here leaves just the concurrent-collision race, which - // no pre-flight check can close. - if ( - updates.name && - table.schema.columns.some( - (c) => - c.name.toLowerCase() === updates.name?.toLowerCase() && - !columnMatchesRef(c, validated.columnName) - ) - ) { - return NextResponse.json( - { error: `Column "${updates.name}" already exists` }, - { status: 400 } - ) - } - if ( - currentColumn?.workflowGroupId && - (updates.required !== undefined || updates.unique !== undefined) - ) { - return NextResponse.json( - { - error: `Cannot change constraints on workflow-output column "${currentColumn.name}". Constraints aren't applicable to columns whose values come from workflow execution.`, - }, - { status: 400 } - ) - } - if (updates.unique === true && !columnTypeById(resultingType).supportsUnique) { + const outcome = await performUpdateTableColumn({ + table, + columnName: validated.columnName, + userId, + updates: validated.updates, + requestId, + request, + }) + if (!outcome.success || !outcome.table) { return NextResponse.json( - { error: `Cannot set a ${resultingType} column as unique` }, - { status: 400 } - ) - } - - if (typeChanging) { - updatedTable = await updateColumnType( - { - tableId, - columnName: columnRef, - newType: updates.type as NonNullable, - ...(updates.options !== undefined ? { options: updates.options } : {}), - ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), - ...(updates.currencyCode !== undefined ? { currencyCode: updates.currencyCode } : {}), - // Forwarded so the conversion validates against the constraint this - // same request is about to set, not the column's current one. - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - ...renameWithTypedWrite, - }, - requestId - ) - } else if (updates.currencyCode !== undefined) { - // Re-denominating an existing currency column: schema-only, no cell - // rewrite. Reached only when the type is unchanged — a conversion INTO - // currency carries the code through `updateColumnType` above. - updatedTable = await updateColumnCurrency( - { - tableId, - columnName: columnRef, - currencyCode: updates.currencyCode, - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - ...renameWithTypedWrite, - }, - requestId - ) - } else if (updates.options !== undefined || updates.multiple !== undefined) { - updatedTable = await updateColumnOptions( - { - tableId, - columnName: columnRef, - options: updates.options ?? currentColumn?.options ?? [], - ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), - // Forwarded so the removal guard validates against the constraint this - // same request is about to set, not the column's current one. - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - ...renameWithTypedWrite, - }, - requestId - ) - } - - // Skipped whenever a typed write ran: that write already applied and - // validated these, in one transaction with the change they accompany. - if (constraintsWriteRuns) { - updatedTable = await updateColumnConstraints( - { - tableId, - columnName: columnRef, - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - ...(updates.name ? { newName: updates.name } : {}), - }, - requestId + { error: outcome.error ?? 'Failed to update column' }, + { status: statusForOrchestrationError(outcome.errorCode) } ) } - // A rename rides along with the LAST write above, inside that write's - // transaction — a rename is metadata-only (rows key on the stable column - // id), so nothing forces it to be its own write, and folding it in is what - // stops a combined request from committing one half and then failing. Only - // a rename with nothing to ride on runs standalone. - if (updates.name && !updatedTable) { - updatedTable = await renameColumn( - { tableId, oldName: columnRef, newName: updates.name }, - requestId - ) - } - - if (!updatedTable) { - return NextResponse.json({ error: 'No updates specified' }, { status: 400 }) - } + // Live-collab: tell open viewers the change landed so they refetch. signalTableSchemaChanged(tableId) - recordAudit({ - workspaceId: validated.workspaceId, - actorId: userId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: table.name, - description: `Updated column "${validated.columnName}" in table "${table.name}"`, - metadata: { columnName: validated.columnName, updates }, - request, - }) - return NextResponse.json({ success: true, data: { - columns: updatedTable.schema.columns.map(normalizeColumn), + columns: outcome.table.schema.columns.map(normalizeColumn), }, }) } catch (error) { - const lockError = tableLockErrorResponse(error) - if (lockError) return lockError const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse - if (error instanceof Error) { - const msg = error.message - if (msg.includes('not found') || msg.includes('Table not found')) { - return NextResponse.json({ error: msg }, { status: 404 }) - } - if ( - msg.includes('already exists') || - msg.includes('Cannot delete the last column') || - msg.includes('Cannot set column') || - msg.includes('Invalid column') || - msg.includes('exceeds maximum') || - msg.includes('incompatible') || - msg.includes('duplicate') || - msg.includes('option') || - msg.includes('currency') || - msg.includes('is already type') - ) { - return NextResponse.json({ error: msg }, { status: 400 }) - } - } - logger.error(`[${requestId}] Error updating column in table:`, error) return NextResponse.json({ error: 'Failed to update column' }, { status: 500 }) } @@ -445,14 +229,8 @@ export const DELETE = withRouteHandler( const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse - if (error instanceof Error) { - if (error.message.includes('not found') || error.message === 'Table not found') { - return NextResponse.json({ error: error.message }, { status: 404 }) - } - if (error.message.includes('Cannot delete') || error.message.includes('last column')) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified logger.error(`[${requestId}] Error deleting column from table:`, error) return NextResponse.json({ error: 'Failed to delete column' }, { status: 500 }) diff --git a/apps/sim/app/api/v1/tables/[tableId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/route.ts index c06492d02b7..149bc674651 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/route.ts @@ -1,11 +1,12 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { v1DeleteTableContract, v1GetTableContract } from '@/lib/api/contracts/v1/tables' import { parseRequest } from '@/lib/api/server' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { deleteTable, type TableSchema } from '@/lib/table' +import type { TableSchema } from '@/lib/table' +import { performDeleteTable } from '@/lib/table/orchestration' import { accessError, checkAccess, @@ -139,18 +140,13 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - await deleteTable(tableId, requestId) - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.TABLE_DELETED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: result.table.name, - description: `Archived table "${result.table.name}"`, - request, - }) + const outcome = await performDeleteTable({ table: result.table, userId, requestId, request }) + if (!outcome.success) { + return NextResponse.json( + { error: outcome.error ?? 'Failed to delete table' }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts index fe2a5a022ab..ed7a68753f2 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts @@ -1,7 +1,6 @@ import { db } from '@sim/db' import { userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { @@ -10,14 +9,21 @@ import { v1UpdateTableRowContract, } from '@/lib/api/contracts/v1/tables' import { parseRequest } from '@/lib/api/server' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RowData, TableSchema } from '@/lib/table' -import { deleteRow, updateRow } from '@/lib/table' +import { updateRow } from '@/lib/table' import { namedRowMapper } from '@/lib/table/cell-format' import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys' import { signalTableRowsChanged } from '@/lib/table/events' -import { accessError, checkAccess, tableLockErrorResponse } from '@/app/api/table/utils' +import { performDeleteTableRow } from '@/lib/table/orchestration' +import { + accessError, + checkAccess, + orchestrationErrorResponse, + tableLockErrorResponse, +} from '@/app/api/table/utils' import { checkRateLimit, checkWorkspaceScope, @@ -155,12 +161,14 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR table, requestId ) + + // Live-collab: tell open viewers the change landed so they refetch. + signalTableRowsChanged(tableId) // No `cancellationGuard` is passed here, so `updateRow` can't return null // from this caller. Defensive narrowing for TypeScript. if (!updatedRow) { return NextResponse.json({ error: 'Row not found' }, { status: 404 }) } - signalTableRowsChanged(tableId) // Auto-dispatch for user edits is handled inside `updateRow` (mode: 'new'). // Firing a second mode: 'incomplete' dispatch here would race with it AND // bulk-clear sibling-group outputs. @@ -190,21 +198,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse - const errorMessage = toError(error).message - - if (errorMessage === 'Row not found') { - return NextResponse.json({ error: errorMessage }, { status: 404 }) - } - - if ( - errorMessage.includes('Row size exceeds') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('Cannot set unique column') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified logger.error(`[${requestId}] Error updating row:`, error) return NextResponse.json({ error: 'Failed to update row' }, { status: 500 }) @@ -240,9 +235,15 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - // Route through the service (not a raw `db.delete`) so the delete lock is - // enforced — the raw path would return 200 on a locked table. - await deleteRow(result.table, rowId, requestId) + const outcome = await performDeleteTableRow({ table: result.table, rowId, requestId }) + if (!outcome.success) { + return NextResponse.json( + { error: outcome.error ?? 'Failed to delete row' }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } + + // Live-collab: tell open viewers the change landed so they refetch. signalTableRowsChanged(tableId) return NextResponse.json({ @@ -255,9 +256,8 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row } catch (error) { const lockError = tableLockErrorResponse(error) if (lockError) return lockError - if (error instanceof Error && error.message === 'Row not found') { - return NextResponse.json({ error: 'Row not found' }, { status: 404 }) - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified logger.error(`[${requestId}] Error deleting row:`, error) return NextResponse.json({ error: 'Failed to delete row' }, { status: 500 }) } diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts index f89bad3096f..6107e99e4e9 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { v1UpsertTableRowContract } from '@/lib/api/contracts/v1/tables' import { parseRequest } from '@/lib/api/server' @@ -10,7 +9,12 @@ import { upsertRow } from '@/lib/table' import { namedRowMapper } from '@/lib/table/cell-format' import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys' import { signalTableRowsChanged } from '@/lib/table/events' -import { accessError, checkAccess, tableLockErrorResponse } from '@/app/api/table/utils' +import { + accessError, + checkAccess, + orchestrationErrorResponse, + tableLockErrorResponse, +} from '@/app/api/table/utils' import { checkRateLimit, checkWorkspaceScope, @@ -76,6 +80,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser table, requestId ) + + // Live-collab: tell open viewers the change landed so they refetch. signalTableRowsChanged(tableId) return NextResponse.json({ @@ -103,19 +109,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse - const errorMessage = toError(error).message - - if ( - errorMessage.includes('unique column') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('conflictTarget') || - errorMessage.includes('row limit') || - errorMessage.includes('Schema validation') || - errorMessage.includes('Upsert requires') || - errorMessage.includes('Row size exceeds') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified logger.error(`[${requestId}] Error upserting row:`, error) return NextResponse.json({ error: 'Failed to upsert row' }, { status: 500 }) diff --git a/apps/sim/app/api/v1/tables/route.ts b/apps/sim/app/api/v1/tables/route.ts index 82bc6618247..6213fd59053 100644 --- a/apps/sim/app/api/v1/tables/route.ts +++ b/apps/sim/app/api/v1/tables/route.ts @@ -6,7 +6,7 @@ import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createTable, getWorkspaceTableLimits, listTables, type TableSchema } from '@/lib/table' -import { normalizeColumn } from '@/app/api/table/utils' +import { normalizeColumn, orchestrationErrorResponse } from '@/app/api/table/utils' import { checkRateLimit, createRateLimitResponse, @@ -171,18 +171,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse - if (error instanceof Error) { - if (error.message.includes('maximum table limit')) { - return NextResponse.json({ error: error.message }, { status: 403 }) - } - if ( - error.message.includes('Invalid table name') || - error.message.includes('Invalid schema') || - error.message.includes('already exists') - ) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified logger.error(`[${requestId}] Error creating table:`, error) return NextResponse.json({ error: 'Failed to create table' }, { status: 500 }) diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts index 7068239e134..304d69f63d8 100644 --- a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts @@ -8,11 +8,11 @@ import { v1UndeployWorkflowContract, } from '@/lib/api/contracts/v1/workflows' import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' -import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, diff --git a/apps/sim/app/api/v1/workflows/[id]/export/route.ts b/apps/sim/app/api/v1/workflows/[id]/export/route.ts index b445e81a3f1..f7ee32c26cb 100644 --- a/apps/sim/app/api/v1/workflows/[id]/export/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/export/route.ts @@ -4,16 +4,10 @@ import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import type { Edge } from 'reactflow' -import { - type V1WorkflowExportPayload, - v1ExportWorkflowContract, -} from '@/lib/api/contracts/v1/workflows' +import { v1ExportWorkflowContract } from '@/lib/api/contracts/v1/workflows' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' -import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer' -import { parseWorkflowVariables } from '@/lib/workflows/variables/parse' +import { buildWorkflowExportPayload } from '@/lib/workflows/operations/export-workflow' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, @@ -26,70 +20,14 @@ const logger = createLogger('V1WorkflowExportAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -type ExportedEdge = V1WorkflowExportPayload['state']['edges'][number] - -/** - * Projects a persisted ReactFlow edge onto the wire shape declared by the - * response contract. Field-by-field rather than a spread because ReactFlow's - * `Edge` is looser than the contract in three places: handles are `null` when - * unset (the contract and the importer both model absent as `undefined`), - * `label` is a `ReactNode`, and the marker fields accept an `EdgeMarker` - * object. Non-serializable values in those slots are dropped rather than - * emitted as `{}`. - */ -function toExportedEdge(edge: Edge): ExportedEdge { - return { - id: edge.id, - source: edge.source, - target: edge.target, - sourceHandle: edge.sourceHandle ?? undefined, - targetHandle: edge.targetHandle ?? undefined, - type: edge.type, - animated: edge.animated, - style: edge.style as Record | undefined, - data: edge.data, - label: typeof edge.label === 'string' ? edge.label : undefined, - labelStyle: edge.labelStyle as Record | undefined, - labelShowBg: edge.labelShowBg, - labelBgStyle: edge.labelBgStyle as Record | undefined, - labelBgPadding: edge.labelBgPadding, - labelBgBorderRadius: edge.labelBgBorderRadius, - markerStart: typeof edge.markerStart === 'string' ? edge.markerStart : undefined, - markerEnd: typeof edge.markerEnd === 'string' ? edge.markerEnd : undefined, - } -} - /** * GET /api/v1/workflows/[id]/export * * Exports a workflow as a portable JSON envelope that - * `POST /api/v1/workflows/import` accepts verbatim. - * - * Unlike the admin export (`/api/v1/admin/workflows/[id]/export`), which emits - * the raw state for backup/restore, this surface runs the payload through - * `sanitizeForExport`, which nulls three classes of sub-block value: - * - `password: true` fields, unless the value is a whole `{{ENV_VAR}}` - * reference, which is preserved so the import resolves it in the target - * workspace; - * - `oauth-input` credentials; - * - **workspace-scoped bindings** — `knowledge-base-selector`, `file-selector`, - * `channel-selector`, `project-selector`, `folder-selector`, - * `mcp-server-selector` and friends, plus fields keyed `knowledgeBaseId`, - * `fileId`, `channelId`, `projectId`, `documentId`, `tagFilters`. These point - * at rows that do not exist in another workspace, so they are cleared rather - * than carried across as dangling ids. - * - * The last class means an export is **not** a byte-for-byte clone even when - * re-imported into the same workspace: those bindings come back empty and must - * be re-selected. This matches the in-app export and is documented on the - * public endpoint so callers do not expect otherwise. - * - * Workflow **variables** are emitted as stored, matching `GET - * /api/v1/workflows/[id]` and the in-app export. Variables are plaintext - * workflow configuration readable by anyone with workspace read (the same - * permission this route requires); secrets belong in environment variables, - * which travel as unresolved `{{ENV_VAR}}` references. Redacting them here - * would break import round-trips without narrowing access. + * `POST /api/v1/workflows/import` accepts verbatim. Payload assembly and the + * sanitization guarantees are documented on the shared + * {@link buildWorkflowExportPayload}; this route authenticates and renders the + * v1 envelope. */ export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { @@ -122,45 +60,11 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } - const normalizedData = await loadWorkflowFromNormalizedTables(id) - if (!normalizedData) { + const payload = await buildWorkflowExportPayload(workflowData) + if (!payload) { return NextResponse.json({ error: 'Workflow state not found' }, { status: 404 }) } - const sanitized = sanitizeForExport({ - blocks: normalizedData.blocks, - edges: normalizedData.edges, - loops: normalizedData.loops, - parallels: normalizedData.parallels, - metadata: { - name: workflowData.name, - description: workflowData.description ?? undefined, - }, - variables: parseWorkflowVariables(workflowData.variables), - }) - - const payload: V1WorkflowExportPayload = { - version: '1.0', - exportedAt: sanitized.exportedAt, - workflow: { - id: workflowData.id, - name: workflowData.name, - description: workflowData.description, - workspaceId: workflowData.workspaceId, - folderId: workflowData.folderId, - }, - state: { - ...sanitized.state, - edges: sanitized.state.edges.map(toExportedEdge), - metadata: { - ...sanitized.state.metadata, - name: workflowData.name, - description: workflowData.description ?? undefined, - exportedAt: sanitized.exportedAt, - }, - }, - } - recordAudit({ workspaceId: workflowData.workspaceId, actorId: userId, diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts index a0779babf51..d015ba4b024 100644 --- a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts @@ -7,10 +7,10 @@ import { v1RollbackWorkflowContract, } from '@/lib/api/contracts/v1/workflows' import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performActivateVersion } from '@/lib/workflows/orchestration' -import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { diff --git a/apps/sim/app/api/v1/workflows/import/route.ts b/apps/sim/app/api/v1/workflows/import/route.ts index dc602b987db..762be48852c 100644 --- a/apps/sim/app/api/v1/workflows/import/route.ts +++ b/apps/sim/app/api/v1/workflows/import/route.ts @@ -1,32 +1,17 @@ -import { db } from '@sim/db' -import { workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { - assertFolderInWorkspace, - assertFolderMutable, - FolderLockedError, - FolderNotFoundError, -} from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { truncate } from '@sim/utils/string' -import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { - V1_IMPORT_DESCRIPTION_MAX_LENGTH, - V1_IMPORT_NAME_MAX_LENGTH, type V1ImportWorkflowData, v1ImportWorkflowContract, } from '@/lib/api/contracts/v1/workflows' -import { workflowStateSchema } from '@/lib/api/contracts/workflows' -import { parseRequest, serializeZodIssues } from '@/lib/api/server' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' -import { performCreateWorkflow } from '@/lib/workflows/orchestration' -import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' -import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state' -import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' -import { normalizeImportedVariables } from '@/lib/workflows/variables/parse' +import { + importWorkflowIntoWorkspace, + MAX_IMPORT_BODY_BYTES, +} from '@/lib/workflows/operations/import-workflow' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, @@ -34,115 +19,19 @@ import { v1ValidationErrorResponse, validateWorkspaceAccess, } from '@/app/api/v1/middleware' -import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('V1WorkflowImportAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * Workflow JSON is a bounded document — a few hundred blocks at the outside. - * Capping well below the platform-wide `DEFAULT_MAX_JSON_BODY_BYTES` (50 MB) - * keeps a hostile caller from buffering a large body before validation runs. - */ -const MAX_IMPORT_BODY_BYTES = 10 * 1024 * 1024 - -const DEFAULT_IMPORTED_WORKFLOW_NAME = 'Imported Workflow' - -const TRUNCATION_SUFFIX = '...' - -/** - * Caps a payload-derived string at `maxLength` *including* the ellipsis. - * `truncate` appends its suffix after slicing, so passing the limit straight - * through would yield `maxLength + 3` characters and overshoot the very bound - * this is enforcing. - */ -function capLength(value: string, maxLength: number): string { - return truncate(value, maxLength - TRUNCATION_SUFFIX.length, TRUNCATION_SUFFIX) -} - -/** - * Reads a dot-delimited path off a parsed payload and returns it only when it - * is a non-empty string, so blank metadata falls through to the next candidate. - */ -function readString(source: unknown, path: string): string | undefined { - let current: unknown = source - for (const segment of path.split('.')) { - if (!current || typeof current !== 'object') return undefined - current = (current as Record)[segment] - } - return typeof current === 'string' && current.trim() ? current.trim() : undefined -} - -/** - * Unwraps the `{ data: ... }` response envelope the export endpoint returns, so - * a caller can pipe an export response body straight into import. - * `parseWorkflowJson` already tolerates this shape when reading the graph; - * mirroring it here keeps metadata resolution from silently falling back to the - * default name for the same payload. - */ -function unwrapResponseEnvelope(payload: unknown): unknown { - if (!payload || typeof payload !== 'object') return payload - const inner = (payload as Record).data - if (!inner || typeof inner !== 'object') return payload - const candidate = inner as Record - return candidate.state || candidate.version || candidate.workflow ? candidate : payload -} - -/** - * Resolves the imported workflow's name and description, preferring explicit - * request overrides and then the payload's own metadata. Accepts every shape - * the importer takes: the export envelope (`workflow.*`, `state.metadata.*`) - * and a bare state (`metadata.*`). - * - * Candidate order deliberately matches `extractWorkflowName` — the resolver the - * in-app importer has always used — so the same payload yields the same name on - * both surfaces. The in-app version additionally falls back to the uploaded - * filename, which has no analogue here; that is the only intended difference. - * - * Payload-derived values are capped at the same bounds the contract applies to - * the explicit overrides, otherwise the declared `maxLength` would not be the - * effective one — a caller could store an unbounded name simply by embedding it - * in the payload instead of passing it as a field. - */ -function resolveImportedMetadata( - rawPayload: unknown, - overrideName?: string, - overrideDescription?: string -): { name: string; description: string } { - const payload = unwrapResponseEnvelope(rawPayload) - - const name = - overrideName || - capLength( - readString(payload, 'state.metadata.name') || - readString(payload, 'workflow.name') || - readString(payload, 'metadata.name') || - DEFAULT_IMPORTED_WORKFLOW_NAME, - V1_IMPORT_NAME_MAX_LENGTH - ) - - const description = - overrideDescription ?? - capLength( - readString(payload, 'state.metadata.description') ?? - readString(payload, 'workflow.description') ?? - readString(payload, 'metadata.description') ?? - '', - V1_IMPORT_DESCRIPTION_MAX_LENGTH - ) - - return { name, description } -} - /** * POST /api/v1/workflows/import * * Creates a new workflow in the target workspace from an export payload - * produced by `GET /api/v1/workflows/{id}/export`. Block, edge, loop and - * parallel ids are regenerated on import, so the same payload can be imported - * repeatedly and alongside its source workflow without collisions. + * produced by `GET /api/v1/workflows/{id}/export`. The shared + * {@link importWorkflowIntoWorkspace} pipeline does the heavy lifting; this + * route authenticates and renders the v1 envelope. */ export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -166,12 +55,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { - workspaceId, - folderId, - name: overrideName, - description: overrideDescription, - } = parsed.data.body + const { workspaceId, folderId, name, description } = parsed.data.body logger.info(`[${requestId}] Importing workflow into workspace ${workspaceId}`, { userId, @@ -181,191 +65,31 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (accessError) return accessError - const [workspaceData] = await db - .select({ id: workspace.id }) - .from(workspace) - .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) - .limit(1) - - if (!workspaceData) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } - - /** - * Ownership before lock state: `assertFolderMutable` walks the folder's - * ancestor chain without filtering on workspace, so checking it first would - * let a caller distinguish a locked folder in someone else's workspace - * (423) from a nonexistent one (404). - */ - if (folderId) { - await assertFolderInWorkspace(folderId, workspaceId) - } - await assertFolderMutable(folderId ?? null) - - const rawWorkflow = parsed.data.body.workflow - const workflowContent = - typeof rawWorkflow === 'string' ? rawWorkflow : JSON.stringify(rawWorkflow) - - const { data: parsedState, errors } = parseWorkflowJson(workflowContent) - if (!parsedState || errors.length > 0) { - return NextResponse.json({ error: `Invalid workflow: ${errors.join(', ')}` }, { status: 400 }) - } - - /** - * Variables are normalized before validation, not after: older exports - * carry them as an array, which is a shape `workflowStateSchema` rightly - * rejects but the importer has always accepted. Normalizing first keeps - * that tolerance while still validating what actually gets persisted. - */ - const variables = normalizeImportedVariables(parsedState.variables) - - /** - * `parseWorkflowJson` only checks that blocks/edges are structurally - * present. The normalized tables are read back through - * {@link workflowStateSchema}, and the client parses that response - * strictly — so a block field with the wrong type (`data.extent: 'child'`, - * `data.count: '5'`) would persist happily here and then throw on every - * subsequent load, leaving a workflow nothing can open. Gate on the same - * schema the canonical `PUT /api/workflows/[id]/state` path enforces. - */ - const stateValidation = workflowStateSchema.safeParse({ ...parsedState, variables }) - if (!stateValidation.success) { - const issue = stateValidation.error.issues[0] - const path = issue?.path.join('.') - return NextResponse.json( - { - error: `Invalid workflow state${path ? ` at ${path}` : ''}: ${issue?.message ?? 'validation failed'}`, - details: serializeZodIssues(stateValidation.error), - }, - { status: 400 } - ) - } - - /** - * Same normalization the editor's `PUT /api/workflows/[id]/state` runs, via - * the one shared implementation — the two import surfaces must land - * byte-identical data for the same payload. - */ - const { state: preparedState, warnings } = prepareWorkflowStateForPersistence(parsedState) - if (warnings.length > 0) { - logger.warn(`[${requestId}] Normalized imported workflow with warnings`, { warnings }) - } - - const workflowState: WorkflowState = { ...parsedState, ...preparedState } - - let parsedPayload: unknown = rawWorkflow - if (typeof rawWorkflow === 'string') { - try { - parsedPayload = JSON.parse(rawWorkflow) - } catch { - parsedPayload = undefined - } - } - - const { name, description } = resolveImportedMetadata( - parsedPayload, - overrideName, - overrideDescription - ) - - const created = await performCreateWorkflow({ - name, - description, + const result = await importWorkflowIntoWorkspace({ workspaceId, folderId, - deduplicate: true, + name, + description, + workflow: parsed.data.body.workflow, userId, requestId, }) - if (!created.success || !created.workflow) { - const status = - created.errorCode === 'conflict' ? 409 : created.errorCode === 'validation' ? 400 : 500 - return NextResponse.json({ error: created.error }, { status }) - } - - const workflowId = created.workflow.id - - /** - * The graph and the variables are written in one transaction so an import - * can never half-land, and any failure deletes the shell row created above - * — a caller that receives an error must not be left with a partially - * imported workflow in their workspace. - */ - try { - await db.transaction(async (tx) => { - const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) - if (!saveResult.success) { - throw new Error(saveResult.error || 'Failed to save workflow state') - } - - if (Object.keys(variables).length > 0) { - await tx - .update(workflow) - .set({ variables, updatedAt: new Date() }) - .where(eq(workflow.id, workflowId)) - } - }) - } catch (error) { - logger.error(`[${requestId}] Failed to persist imported workflow, rolling back`, { - workflowId, - error: getErrorMessage(error, 'Unknown error'), - }) - /** - * The rollback runs under the same conditions that just failed the write, - * so it can fail too. Losing it must not turn into an unlogged orphan: - * the caller still gets a 500, but the id is recorded loudly enough to - * clean up. - */ - try { - await db.delete(workflow).where(eq(workflow.id, workflowId)) - } catch (rollbackError) { - logger.error( - `[${requestId}] Rollback failed, workflow ${workflowId} is orphaned in workspace ${workspaceId}`, - { workflowId, workspaceId, error: getErrorMessage(rollbackError, 'Unknown error') } - ) - } - return NextResponse.json({ error: 'Failed to save workflow state' }, { status: 500 }) - } - - /** - * Matches the canonical state-write path: agent blocks may carry inline - * custom-tool definitions that must exist as workspace rows to be - * resolvable at execution. Failures are logged, not fatal — the workflow - * itself imported successfully. - */ - try { - const { saved, errors: toolErrors } = await extractAndPersistCustomTools( - workflowState, - workspaceId, - userId + if (!result.success) { + return NextResponse.json( + { error: result.error, ...(result.details !== undefined && { details: result.details }) }, + { status: result.status } ) - if (saved > 0 || toolErrors.length > 0) { - logger.info(`[${requestId}] Persisted ${saved} custom tool(s) from import`, { - workflowId, - errors: toolErrors, - }) - } - } catch (error) { - logger.error(`[${requestId}] Failed to persist custom tools from import`, { - workflowId, - error: getErrorMessage(error, 'Unknown error'), - }) } - logger.info(`[${requestId}] Imported workflow ${workflowId} into workspace ${workspaceId}`, { - name: created.workflow.name, - blocksCount: Object.keys(workflowState.blocks).length, - }) - const data: V1ImportWorkflowData = { - id: workflowId, - name: created.workflow.name, - description: created.workflow.description ?? null, - workspaceId, - folderId: created.workflow.folderId ?? null, - createdAt: created.workflow.createdAt.toISOString(), - updatedAt: created.workflow.updatedAt.toISOString(), + id: result.workflow.id, + name: result.workflow.name, + description: result.workflow.description, + workspaceId: result.workflow.workspaceId, + folderId: result.workflow.folderId, + createdAt: result.workflow.createdAt.toISOString(), + updatedAt: result.workflow.updatedAt.toISOString(), } const limits = await getUserLimits(userId) @@ -373,9 +97,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(apiResponse.body, { status: 201, headers: apiResponse.headers }) } catch (error: unknown) { - if (error instanceof FolderLockedError || error instanceof FolderNotFoundError) { - return NextResponse.json({ error: error.message }, { status: error.status }) - } const message = getErrorMessage(error, 'Unknown error') logger.error(`[${requestId}] Workflow import error`, { error: message }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) diff --git a/apps/sim/app/api/v2/audit-logs/[id]/route.ts b/apps/sim/app/api/v2/audit-logs/[id]/route.ts new file mode 100644 index 00000000000..bef7e2d43ce --- /dev/null +++ b/apps/sim/app/api/v2/audit-logs/[id]/route.ts @@ -0,0 +1,80 @@ +import { db } from '@sim/db' +import { auditLog } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { v2GetAuditLogContract } from '@/lib/api/contracts/v2/audit-logs' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' +import { 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') + +export const revalidate = 0 + +/** + * GET /api/v2/audit-logs/[id] + * + * Returns a single audit log entry scoped to the authenticated user's + * organization. Org-scoped (not workspace-scoped). Unlike v1, authorization + * (`checkRateLimit` → `validateEnterpriseAuditAccess`) runs BEFORE the untrusted + * param is parsed, fixing the v1 ordering inconsistency. The org-scope predicate + * is folded into the lookup so a non-org log reads as 404 (existence is not + * leaked). + */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'audit-logs') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const authResult = await resolveEnterpriseAuditAccess(userId) + if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) + + const parsed = await parseRequest(v2GetAuditLogContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { organizationId, orgMemberIds } = authResult.context + + const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId) + const scopeCondition = buildOrgScopeCondition({ + organizationId, + orgWorkspaceIds, + orgMemberIds, + includeDeparted: true, + }) + + const [log] = await db + .select() + .from(auditLog) + .where(and(eq(auditLog.id, id), scopeCondition)) + .limit(1) + + if (!log) return v2Error('NOT_FOUND', 'Audit log not found') + + return v2Data(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..a264e5f47a4 --- /dev/null +++ b/apps/sim/app/api/v2/audit-logs/route.ts @@ -0,0 +1,107 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' +import { 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 { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2AuditLogsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/audit-logs + * + * Lists audit logs scoped to the authenticated user's organization. Org-scoped + * (not workspace-scoped): `resolveWorkspaceAccess` is intentionally NOT used — + * access is gated by enterprise org admin/owner membership. Auth ordering + * matches v1: `checkRateLimit` → `validateEnterpriseAuditAccess` run before the + * untrusted query is parsed. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'audit-logs') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const authResult = await resolveEnterpriseAuditAccess(userId) + if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) + + const { organizationId, orgMemberIds } = authResult.context + + const parsed = await parseRequest( + v2ListAuditLogsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const params = parsed.data.query + + if (params.actorId && !orgMemberIds.includes(params.actorId)) { + return v2Error('BAD_REQUEST', 'actorId is not a member of your organization') + } + + const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId) + + if (params.workspaceId && !orgWorkspaceIds.includes(params.workspaceId)) { + return v2Error('BAD_REQUEST', 'workspaceId does not belong to your organization') + } + + const scopeCondition = buildOrgScopeCondition({ + organizationId, + orgWorkspaceIds, + orgMemberIds, + includeDeparted: params.includeDeparted, + }) + const filterConditions = buildFilterConditions({ + action: params.action, + resourceType: params.resourceType, + resourceId: params.resourceId, + workspaceId: params.workspaceId, + actorId: params.actorId, + startDate: params.startDate, + endDate: params.endDate, + }) + + const { data, nextCursor } = await queryAuditLogs( + [scopeCondition, ...filterConditions], + params.limit, + params.cursor + ) + + return v2CursorList(data.map(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/billing/usage/logs/route.test.ts b/apps/sim/app/api/v2/billing/usage/logs/route.test.ts new file mode 100644 index 00000000000..ed7d0390381 --- /dev/null +++ b/apps/sim/app/api/v2/billing/usage/logs/route.test.ts @@ -0,0 +1,135 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apportionCredits } from '@/lib/billing/credits/conversion' + +const { mockCheckRateLimit, mockGetUserUsageLogs, mockGetUsageCreditsByLogId } = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockGetUserUsageLogs: vi.fn(), + mockGetUsageCreditsByLogId: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, +})) + +vi.mock('@/lib/billing/core/usage-log', () => ({ + getUserUsageLogs: mockGetUserUsageLogs, + getUsageCreditsByLogId: mockGetUsageCreditsByLogId, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET } from '@/app/api/v2/billing/usage/logs/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'personal', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callLogs(query = '') { + return GET(new NextRequest(`http://localhost:3000/api/v2/billing/usage/logs${query}`)) +} + +describe('GET /api/v2/billing/usage/logs', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockGetUserUsageLogs.mockResolvedValue({ + logs: [ + { + id: 'log-1', + createdAt: '2026-07-01T00:00:00.000Z', + category: 'model', + source: 'copilot', + description: 'claude-sonnet', + cost: 0.06, + }, + ], + summary: { totalCost: 0, bySource: {} }, + pagination: { hasMore: false }, + }) + mockGetUsageCreditsByLogId.mockResolvedValue( + apportionCredits([{ key: 'log-1', dollars: 0.06 }]) + ) + }) + + it('returns credit-denominated rows in the cursor envelope, no dollar costs', async () => { + const res = await callLogs() + expect(res.status).toBe(200) + const body = await res.json() + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + id: 'log-1', + createdAt: '2026-07-01T00:00:00.000Z', + source: 'copilot', + workflowName: null, + creditCost: 12, + }, + ]) + expect(JSON.stringify(body)).not.toContain('ollarCost') + }) + + it('forwards the cursor when more rows remain', async () => { + mockGetUserUsageLogs.mockResolvedValue({ + logs: [], + summary: { totalCost: 0, bySource: {} }, + pagination: { hasMore: true, nextCursor: 'log-42' }, + }) + mockGetUsageCreditsByLogId.mockResolvedValue({}) + const body = await (await callLogs()).json() + expect(body.nextCursor).toBe('log-42') + }) + + it('pins a workspace API key to its own workspace', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + keyType: 'workspace', + workspaceId: 'ws-1', + }) + const res = await callLogs() + expect(res.status).toBe(200) + expect(mockGetUserUsageLogs).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ workspaceId: 'ws-1' }) + ) + }) + + it('403s a workspace API key asking for a different workspace', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + keyType: 'workspace', + workspaceId: 'ws-1', + }) + const res = await callLogs('?workspaceId=ws-2') + expect(res.status).toBe(403) + expect(mockGetUserUsageLogs).not.toHaveBeenCalled() + }) + + it('rejects "custom" period without a startDate', async () => { + const res = await callLogs('?period=custom') + expect(res.status).toBe(400) + expect(mockGetUserUsageLogs).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue({ + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2026-01-01T01:00:00Z'), + retryAfterMs: 1000, + }) + const res = await callLogs() + expect(res.status).toBe(429) + }) +}) diff --git a/apps/sim/app/api/v2/billing/usage/logs/route.ts b/apps/sim/app/api/v2/billing/usage/logs/route.ts new file mode 100644 index 00000000000..93621a9606b --- /dev/null +++ b/apps/sim/app/api/v2/billing/usage/logs/route.ts @@ -0,0 +1,92 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2ListUsageLogsContract } from '@/lib/api/contracts/v2/billing' +import { parseRequest } from '@/lib/api/server' +import { + getUsageCreditsByLogId, + getUserUsageLogs, + type UsageLogSource, +} from '@/lib/billing/core/usage-log' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { v2BillingWorkspaceFilter } from '@/app/api/v2/billing/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2BillingUsageLogsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/billing/usage/logs — Cursor-paged, credit-denominated ledger of + * the account's usage events. The per-source aggregate lives on + * `GET /api/v2/billing/usage`; this is the row-level detail. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'billing-usage') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListUsageLogsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { source, workspaceId, period, startDate, endDate, limit, cursor } = parsed.data.query + + const workspaceFilter = v2BillingWorkspaceFilter(rateLimit, workspaceId) + if (!workspaceFilter.ok) return workspaceFilter.response + + const dateRange = resolveDateRange(period, startDate, endDate) + const filter = { + source: source as UsageLogSource | undefined, + workspaceId: workspaceFilter.workspaceId, + startDate: dateRange.startDate, + endDate: dateRange.endDate, + } + + const [result, creditsByLogId] = await Promise.all([ + getUserUsageLogs(userId, { ...filter, limit, cursor, includeSummary: false }), + getUsageCreditsByLogId(userId, filter), + ]) + + const items = result.logs.map((log) => ({ + id: log.id, + createdAt: log.createdAt, + source: log.source, + workflowName: log.workflowName ?? null, + creditCost: creditsByLogId[log.id] ?? 0, + })) + + return v2CursorList( + items, + result.pagination.hasMore ? (result.pagination.nextCursor ?? null) : null, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error listing usage logs`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/billing/usage/route.test.ts b/apps/sim/app/api/v2/billing/usage/route.test.ts new file mode 100644 index 00000000000..5e2a63f5adf --- /dev/null +++ b/apps/sim/app/api/v2/billing/usage/route.test.ts @@ -0,0 +1,144 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockGetUserUsageLogs, + mockCheckServerSideUsageLimits, + mockGetHighestPrioritySubscription, + mockDeriveBillingContext, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockGetUserUsageLogs: vi.fn(), + mockCheckServerSideUsageLimits: vi.fn(), + mockGetHighestPrioritySubscription: vi.fn(), + mockDeriveBillingContext: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, +})) + +vi.mock('@/lib/billing', () => ({ + checkServerSideUsageLimits: mockCheckServerSideUsageLimits, +})) + +vi.mock('@/lib/billing/core/subscription', () => ({ + getHighestPrioritySubscription: mockGetHighestPrioritySubscription, +})) + +vi.mock('@/lib/billing/core/usage-log', () => ({ + deriveBillingContext: mockDeriveBillingContext, + getUserUsageLogs: mockGetUserUsageLogs, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET } from '@/app/api/v2/billing/usage/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'personal', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callSummary(query = '') { + return GET(new NextRequest(`http://localhost:3000/api/v2/billing/usage${query}`)) +} + +describe('GET /api/v2/billing/usage', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro' }) + mockDeriveBillingContext.mockReturnValue({ + billingEntity: { type: 'user', id: 'user-1' }, + billingPeriod: { + start: new Date('2026-07-01T00:00:00Z'), + end: new Date('2026-08-01T00:00:00Z'), + }, + }) + mockCheckServerSideUsageLimits.mockResolvedValue({ + isExceeded: false, + currentUsage: 2.5, + limit: 100, + }) + mockGetUserUsageLogs.mockResolvedValue({ + logs: [], + summary: { totalCost: 2.5, bySource: { workflow: 1.9, copilot: 0.6 } }, + pagination: { hasMore: false }, + }) + }) + + it('returns the billing-period summary with per-source credits, no dollars', async () => { + const res = await callSummary() + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data).toEqual({ + period: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' }, + totalCredits: 500, + bySourceCredits: { workflow: 380, copilot: 120 }, + limitCredits: 20000, + plan: 'pro', + }) + expect(JSON.stringify(body)).not.toContain('dollar') + }) + + it('queries the ledger summary over the derived billing period', async () => { + await callSummary() + expect(mockGetUserUsageLogs).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ + startDate: new Date('2026-07-01T00:00:00Z'), + endDate: new Date('2026-08-01T00:00:00Z'), + includeSummary: true, + }) + ) + }) + + it('pins a workspace API key to its own workspace', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + keyType: 'workspace', + workspaceId: 'ws-1', + }) + const res = await callSummary() + expect(res.status).toBe(200) + expect(mockGetUserUsageLogs).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ workspaceId: 'ws-1' }) + ) + }) + + it('403s a workspace API key asking for a different workspace', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + keyType: 'workspace', + workspaceId: 'ws-1', + }) + const res = await callSummary('?workspaceId=ws-2') + expect(res.status).toBe(403) + expect((await res.json()).error.code).toBe('FORBIDDEN') + expect(mockGetUserUsageLogs).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue({ + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2026-01-01T01:00:00Z'), + retryAfterMs: 1000, + }) + const res = await callSummary() + expect(res.status).toBe(429) + }) +}) diff --git a/apps/sim/app/api/v2/billing/usage/route.ts b/apps/sim/app/api/v2/billing/usage/route.ts new file mode 100644 index 00000000000..60d826fc5c8 --- /dev/null +++ b/apps/sim/app/api/v2/billing/usage/route.ts @@ -0,0 +1,92 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { type V2UsageSummaryData, v2GetUsageSummaryContract } from '@/lib/api/contracts/v2/billing' +import { parseRequest } from '@/lib/api/server' +import { checkServerSideUsageLimits } from '@/lib/billing' +import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import { deriveBillingContext, getUserUsageLogs } from '@/lib/billing/core/usage-log' +import { dollarsToCredits } from '@/lib/billing/credits/conversion' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { v2BillingWorkspaceFilter } from '@/app/api/v2/billing/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2BillingUsageAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/billing/usage — Current-billing-period usage summary with the + * per-source credit breakdown, for external monitoring (e.g. alerting on + * Copilot consumption before an overage). Credits only — dollar costs and + * rate-limit internals are not part of this surface. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'billing-usage') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2GetUsageSummaryContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const workspaceFilter = v2BillingWorkspaceFilter(rateLimit, parsed.data.query.workspaceId) + if (!workspaceFilter.ok) return workspaceFilter.response + + const subscription = await getHighestPrioritySubscription(userId) + const { billingPeriod } = deriveBillingContext(userId, subscription) + + const [usageCheck, ledger] = await Promise.all([ + checkServerSideUsageLimits(userId, subscription), + getUserUsageLogs(userId, { + workspaceId: workspaceFilter.workspaceId, + startDate: billingPeriod.start, + endDate: billingPeriod.end, + limit: 1, + includeSummary: true, + }), + ]) + + const bySourceCredits = Object.fromEntries( + Object.entries(ledger.summary.bySource).map(([source, cost]) => [ + source, + dollarsToCredits(cost), + ]) + ) + + const data: V2UsageSummaryData = { + period: { + start: billingPeriod.start.toISOString(), + end: billingPeriod.end.toISOString(), + }, + totalCredits: dollarsToCredits(ledger.summary.totalCost), + bySourceCredits, + limitCredits: dollarsToCredits(usageCheck.limit), + plan: subscription?.plan || 'free', + } + + return v2Data(data, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error building usage summary`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/billing/utils.ts b/apps/sim/app/api/v2/billing/utils.ts new file mode 100644 index 00000000000..3e7eea9ca70 --- /dev/null +++ b/apps/sim/app/api/v2/billing/utils.ts @@ -0,0 +1,30 @@ +import type { NextResponse } from 'next/server' +import type { RateLimitResult } from '@/app/api/v1/middleware' +import { v2Error } from '@/app/api/v2/lib/response' + +type BillingWorkspaceFilter = + | { ok: true; workspaceId: string | undefined } + | { ok: false; response: NextResponse } + +/** + * Resolves the effective `workspaceId` ledger filter for the caller's key. + * Personal keys read the account's full ledger with whatever filter they asked + * for; a workspace-scoped key is pinned to its own workspace — the filter + * defaults to the key's workspace and an explicit mismatch is rejected rather + * than silently ignored. + */ +export function v2BillingWorkspaceFilter( + rateLimit: RateLimitResult, + requestedWorkspaceId: string | undefined +): BillingWorkspaceFilter { + if (rateLimit.keyType !== 'workspace') { + return { ok: true, workspaceId: requestedWorkspaceId } + } + if (requestedWorkspaceId && requestedWorkspaceId !== rateLimit.workspaceId) { + return { + ok: false, + response: v2Error('FORBIDDEN', 'API key is not authorized for this workspace'), + } + } + return { ok: true, workspaceId: rateLimit.workspaceId } +} diff --git a/apps/sim/app/api/v2/credentials/[id]/route.test.ts b/apps/sim/app/api/v2/credentials/[id]/route.test.ts new file mode 100644 index 00000000000..b76997daad2 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/[id]/route.test.ts @@ -0,0 +1,414 @@ +/** + * @vitest-environment node + * + * Public v2 credential detail: workspace scoping of the id, the 404 mask for a + * credential the caller has no membership on, and secret-free reads. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetWorkspaceCredential, + mockGetCredentialActorContext, + mockPerformUpdateCredential, + mockPerformDeleteCredential, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetWorkspaceCredential: vi.fn(), + mockGetCredentialActorContext: vi.fn(), + mockPerformUpdateCredential: vi.fn(), + mockPerformDeleteCredential: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/credentials/queries', () => ({ + getWorkspaceCredential: mockGetWorkspaceCredential, +})) + +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mockGetCredentialActorContext, +})) + +vi.mock('@/lib/credentials/orchestration', async () => { + const actual = await import('@/lib/credentials/orchestration/credential-create') + return { + isProviderOutageCode: actual.isProviderOutageCode, + performUpdateCredential: mockPerformUpdateCredential, + performDeleteCredential: mockPerformDeleteCredential, + } +}) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { DELETE, GET, PATCH } from '@/app/api/v2/credentials/[id]/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +function buildRow(overrides: Record = {}) { + return { + id: 'cred_abc123', + workspaceId: WORKSPACE_ID, + type: 'service_account', + displayName: 'Zoom account acct_123', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: 'encrypted-blob', + createdBy: 'user-1', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +const routeContext = () => ({ params: Promise.resolve({ id: 'cred_abc123' }) }) +const url = (query = `workspaceId=${WORKSPACE_ID}`) => + `http://localhost:3000/api/v2/credentials/cred_abc123?${query}` + +const callGet = (query?: string) => GET(new NextRequest(url(query)), routeContext()) +const callDelete = (query?: string) => + DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext()) + +function callPatch(body: unknown) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/credentials/cred_abc123', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + routeContext() + ) +} + +describe('GET /api/v2/credentials/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCredential.mockResolvedValue(buildRow()) + mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'admin' }, isAdmin: true }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockGetWorkspaceCredential).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callGet('') + expect(res.status).toBe(400) + expect(mockGetWorkspaceCredential).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callGet() + expect(res.status).toBe(403) + expect(mockGetWorkspaceCredential).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callGet() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the credential belongs to another workspace', async () => { + mockGetWorkspaceCredential.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('masks a credential the caller has no membership on as 404', async () => { + mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false }) + const res = await callGet() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('returns the public shape with no secret material', async () => { + const res = await callGet() + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.credential).toEqual({ + id: 'cred_abc123', + type: 'service_account', + displayName: 'Zoom account acct_123', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + hasServiceAccountKey: true, + role: 'admin', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }) + expect(JSON.stringify(body)).not.toContain('encrypted-blob') + }) +}) + +describe('PATCH /api/v2/credentials/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCredential.mockResolvedValue(buildRow()) + mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'admin' }, isAdmin: true }) + mockPerformUpdateCredential.mockResolvedValue({ success: true }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + + expect(res.status).toBe(404) + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('400s when no field to change is supplied', async () => { + const res = await callPatch({ workspaceId: WORKSPACE_ID }) + expect(res.status).toBe(400) + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('400s when the body carries an unknown field', async () => { + const res = await callPatch({ workspaceId: WORKSPACE_ID, bogus: 'x' }) + expect(res.status).toBe(400) + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + expect(res.status).toBe(403) + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the credential belongs to another workspace', async () => { + mockGetWorkspaceCredential.mockResolvedValue(null) + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + expect(res.status).toBe(404) + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('403s when the caller is not a credential admin', async () => { + mockPerformUpdateCredential.mockResolvedValue({ + success: false, + error: 'Credential admin permission required', + errorCode: 'forbidden', + }) + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + expect(res.status).toBe(403) + expect((await res.json()).error.code).toBe('FORBIDDEN') + }) + + it('gates on workspace read, leaving admin rights to the per-credential check', async () => { + await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + WORKSPACE_ID, + 'read' + ) + }) + + it('masks a credential the caller cannot see as 404, not 403', async () => { + mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false }) + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + expect(res.status).toBe(404) + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('503s when the provider is unreachable during a secret rotation', async () => { + mockPerformUpdateCredential.mockResolvedValue({ + success: false, + error: 'provider_unavailable', + errorCode: 'validation', + providerErrorCode: 'provider_unavailable', + }) + const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'tok' }) + expect(res.status).toBe(503) + expect((await res.json()).error.code).toBe('SERVICE_UNAVAILABLE') + }) + + it('rejects a displayName rename on an env credential instead of dropping it', async () => { + mockGetWorkspaceCredential.mockResolvedValue( + buildRow({ type: 'env_workspace', envKey: 'STRIPE_API_KEY', displayName: 'STRIPE_API_KEY' }) + ) + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('envKey') + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('still allows a description change on an env credential', async () => { + mockGetWorkspaceCredential.mockResolvedValue(buildRow({ type: 'env_workspace' })) + const res = await callPatch({ workspaceId: WORKSPACE_ID, description: 'note' }) + + expect(res.status).toBe(200) + expect(mockPerformUpdateCredential).toHaveBeenCalled() + }) + + it('503s on an Atlassian outage too, not just a token-provider one', async () => { + mockPerformUpdateCredential.mockResolvedValue({ + success: false, + error: 'atlassian_unavailable', + errorCode: 'validation', + providerErrorCode: 'atlassian_unavailable', + }) + const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'tok' }) + expect(res.status).toBe(503) + expect((await res.json()).error.code).toBe('SERVICE_UNAVAILABLE') + }) + + it('keeps a rejected secret a 400, not a 503', async () => { + mockPerformUpdateCredential.mockResolvedValue({ + success: false, + error: 'invalid_credentials', + errorCode: 'validation', + providerErrorCode: 'invalid_credentials', + }) + const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'tok' }) + expect(res.status).toBe(400) + }) + + it('rotates a secret without echoing it back', async () => { + const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'brand-new-token' }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(JSON.stringify(body)).not.toContain('brand-new-token') + expect(mockPerformUpdateCredential).toHaveBeenCalledWith( + expect.objectContaining({ + credentialId: 'cred_abc123', + userId: 'user-1', + apiToken: 'brand-new-token', + }) + ) + }) +}) + +describe('DELETE /api/v2/credentials/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCredential.mockResolvedValue(buildRow()) + mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'admin' }, isAdmin: true }) + mockPerformDeleteCredential.mockResolvedValue({ success: true }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callDelete() + + expect(res.status).toBe(404) + expect(mockPerformDeleteCredential).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callDelete('') + expect(res.status).toBe(400) + expect(mockPerformDeleteCredential).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callDelete() + expect(res.status).toBe(403) + expect(mockPerformDeleteCredential).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callDelete() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the credential belongs to another workspace', async () => { + mockGetWorkspaceCredential.mockResolvedValue(null) + const res = await callDelete() + expect(res.status).toBe(404) + expect(mockPerformDeleteCredential).not.toHaveBeenCalled() + }) + + it('gates on workspace read, leaving admin rights to the per-credential check', async () => { + await callDelete() + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + WORKSPACE_ID, + 'read' + ) + }) + + it('masks a credential the caller cannot see as 404, not 403', async () => { + mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false }) + const res = await callDelete() + expect(res.status).toBe(404) + expect(mockPerformDeleteCredential).not.toHaveBeenCalled() + }) + + it('deletes the credential and acknowledges the id', async () => { + const res = await callDelete() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: { id: 'cred_abc123', deleted: true } }) + expect(mockPerformDeleteCredential).toHaveBeenCalledWith( + expect.objectContaining({ credentialId: 'cred_abc123', userId: 'user-1' }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/credentials/[id]/route.ts b/apps/sim/app/api/v2/credentials/[id]/route.ts new file mode 100644 index 00000000000..d92c016b284 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/[id]/route.ts @@ -0,0 +1,216 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteCredentialContract, + v2GetCredentialContract, + v2UpdateCredentialContract, +} from '@/lib/api/contracts/v2/credentials' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { + isProviderOutageCode, + performDeleteCredential, + performUpdateCredential, +} from '@/lib/credentials/orchestration' +import { getWorkspaceCredential } from '@/lib/credentials/queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2CredentialRow, v2CredentialOrchestrationError } from '@/app/api/v2/credentials/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2CredentialDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RouteContext { + params: Promise<{ id: string }> +} + +/** GET /api/v2/credentials/[id] — Fetch a single credential. Secrets are never returned. */ +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'credential-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetCredentialContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const credential = await getWorkspaceCredential({ workspaceId, credentialId: id }) + if (!credential) return v2Error('NOT_FOUND', 'Credential not found') + + /** + * Workspace access is not credential access: seeing a credential requires a + * membership row (or workspace admin over a shared type). A caller who has + * neither gets 404 rather than 403 so credential existence never leaks to + * someone who cannot use it. + */ + const actor = await getCredentialActorContext(id, userId) + if (!actor.member && !actor.isAdmin) return v2Error('NOT_FOUND', 'Credential not found') + + return v2Data( + { credential: toV2CredentialRow(credential, actor.isAdmin ? 'admin' : 'member') }, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error fetching credential`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/credentials/[id] — Rename, re-describe, or rotate a credential's secret. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'credential-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateCredentialContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, ...changes } = parsed.data.body + + /** + * Credential mutations are gated per credential, not per workspace: + * `performUpdateCredential` requires credential admin, and the internal + * surface applies no workspace-level bar at all. Requiring workspace `write` + * here would lock out a credential admin who only holds `read`. + */ + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + // Tenant-scope the id before the orchestration re-derives access from the + // credential's own workspace. + const existing = await getWorkspaceCredential({ workspaceId, credentialId: id }) + if (!existing) return v2Error('NOT_FOUND', 'Credential not found') + + const actor = await getCredentialActorContext(id, userId) + if (!actor.member && !actor.isAdmin) return v2Error('NOT_FOUND', 'Credential not found') + + /** + * An env credential's display name IS its `envKey` — the lib only applies + * `displayName` to `oauth` and `service_account`, so accepting it here would + * either drop the rename silently (when sent alongside `description`) or + * fail with an unrelated environment-editor message (when sent alone). + */ + if ( + changes.displayName !== undefined && + (existing.type === 'env_workspace' || existing.type === 'env_personal') + ) { + return v2Error( + 'BAD_REQUEST', + 'displayName cannot be set on an environment credential — its name is its envKey. Delete it and create one under the new key.' + ) + } + + const result = await performUpdateCredential({ ...changes, credentialId: id, userId, request }) + + if (!result.success) { + return v2CredentialOrchestrationError( + result.errorCode, + result.error ?? 'Failed to update credential', + { providerUnavailable: isProviderOutageCode(result.providerErrorCode) } + ) + } + + const updated = await getWorkspaceCredential({ workspaceId, credentialId: id }) + if (!updated) return v2Error('NOT_FOUND', 'Credential not found') + + return v2Data({ credential: toV2CredentialRow(updated, 'admin') }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error updating credential`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/credentials/[id] — Delete a credential and revoke what it backed. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'credential-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteCredentialContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + // Gated per credential by `performDeleteCredential`, same as PATCH above. + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const existing = await getWorkspaceCredential({ workspaceId, credentialId: id }) + if (!existing) return v2Error('NOT_FOUND', 'Credential not found') + + /** + * A credential the caller cannot see answers 404, matching GET, so a + * workspace member cannot tell an inaccessible credential from a missing one + * and enumerate ids. A credential they *can* see but cannot administer still + * gets the orchestration's 403 — that distinction is not a leak, since GET + * already shows them the credential. + */ + const actor = await getCredentialActorContext(id, userId) + if (!actor.member && !actor.isAdmin) return v2Error('NOT_FOUND', 'Credential not found') + + const result = await performDeleteCredential({ credentialId: id, userId, request }) + if (!result.success) { + return v2CredentialOrchestrationError( + result.errorCode, + result.error ?? 'Failed to delete credential' + ) + } + + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting credential`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts new file mode 100644 index 00000000000..2bfe1cbfad4 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -0,0 +1,366 @@ +/** + * @vitest-environment node + * + * Public v2 credentials list/create: gate ordering, the write-only treatment of + * secret material, and the exclusion of `oauth` from the creatable types. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockCheckWorkspaceAccess, + mockListVisibleWorkspaceCredentials, + mockPerformCreateCredential, + mockGetCredentialActorContext, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockCheckWorkspaceAccess: vi.fn(), + mockListVisibleWorkspaceCredentials: vi.fn(), + mockPerformCreateCredential: vi.fn(), + mockGetCredentialActorContext: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, +})) + +vi.mock('@/lib/credentials/queries', () => ({ + listVisibleWorkspaceCredentials: mockListVisibleWorkspaceCredentials, +})) + +vi.mock('@/lib/credentials/orchestration', () => ({ + performCreateCredential: mockPerformCreateCredential, +})) + +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mockGetCredentialActorContext, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET, POST } from '@/app/api/v2/credentials/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +function buildVisible(overrides: Record = {}) { + return { + id: 'cred_abc123', + workspaceId: WORKSPACE_ID, + type: 'service_account' as const, + displayName: 'Zoom account acct_123', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + hasServiceAccountKey: true, + role: 'admin' as const, + ...overrides, + } +} + +function buildRow(overrides: Record = {}) { + return { + id: 'cred_abc123', + workspaceId: WORKSPACE_ID, + type: 'service_account', + displayName: 'Zoom account acct_123', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: 'encrypted-blob', + createdBy: 'user-1', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +const callList = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/credentials?${query}`)) + +function callCreate(body: unknown) { + return POST( + new NextRequest('http://localhost:3000/api/v2/credentials', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + +const VALID_BODY = { + workspaceId: WORKSPACE_ID, + type: 'env_workspace', + envKey: 'STRIPE_API_KEY', +} + +describe('GET /api/v2/credentials', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: true }) + mockListVisibleWorkspaceCredentials.mockResolvedValue([buildVisible()]) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callList(`workspaceId=${WORKSPACE_ID}`) + + expect(res.status).toBe(404) + expect(mockListVisibleWorkspaceCredentials).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callList('') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockListVisibleWorkspaceCredentials).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callList(`workspaceId=${WORKSPACE_ID}`) + expect(res.status).toBe(403) + expect(mockListVisibleWorkspaceCredentials).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callList(`workspaceId=${WORKSPACE_ID}`) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('returns the public credential shape with no secret material', async () => { + const res = await callList(`workspaceId=${WORKSPACE_ID}`) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + id: 'cred_abc123', + type: 'service_account', + displayName: 'Zoom account acct_123', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + hasServiceAccountKey: true, + role: 'admin', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + ]) + expect(mockListVisibleWorkspaceCredentials).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: WORKSPACE_ID, userId: 'user-1' }) + ) + }) + + it('passes the type and providerId filters through', async () => { + await callList(`workspaceId=${WORKSPACE_ID}&type=oauth&providerId=slack`) + expect(mockListVisibleWorkspaceCredentials).toHaveBeenCalledWith( + expect.objectContaining({ type: 'oauth', providerId: 'slack' }) + ) + }) + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=${WORKSPACE_ID}&sortBy=name);--`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=${WORKSPACE_ID}&sortOrder=sideways`) + + expect(res.status).toBe(400) + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=${WORKSPACE_ID}&search=`) + + expect(res.status).toBe(400) + }) + + it('forwards search and sort into the query and still terminates pagination', async () => { + const res = await callList( + `workspaceId=${WORKSPACE_ID}&search=report&sortBy=displayName&sortOrder=asc` + ) + + expect(res.status).toBe(200) + expect((await res.json()).nextCursor).toBeNull() + }) +}) + +describe('POST /api/v2/credentials', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformCreateCredential.mockResolvedValue({ + success: true, + credential: buildRow(), + created: true, + }) + mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(404) + expect(mockPerformCreateCredential).not.toHaveBeenCalled() + }) + + it('400s when envKey is missing for an env credential', async () => { + const res = await callCreate({ workspaceId: WORKSPACE_ID, type: 'env_workspace' }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformCreateCredential).not.toHaveBeenCalled() + }) + + it('400s when envKey is not a valid environment variable name', async () => { + const res = await callCreate({ ...VALID_BODY, envKey: 'not-a-valid-name' }) + expect(res.status).toBe(400) + expect(mockPerformCreateCredential).not.toHaveBeenCalled() + }) + + it('400s on an oauth create, which requires the interactive connect flow', async () => { + const res = await callCreate({ + workspaceId: WORKSPACE_ID, + type: 'oauth', + providerId: 'slack', + accountId: 'acct_1', + displayName: 'Slack', + }) + expect(res.status).toBe(400) + expect(mockPerformCreateCredential).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(403) + expect(mockPerformCreateCredential).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('maps a provider outage to 503 rather than a bad request', async () => { + mockPerformCreateCredential.mockResolvedValue({ + success: false, + error: 'provider_unavailable', + errorCode: 'validation', + providerErrorCode: 'provider_unavailable', + providerUnavailable: true, + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(503) + expect((await res.json()).error.code).toBe('SERVICE_UNAVAILABLE') + }) + + it('reports the real role when an idempotent create matches a credential the caller only belongs to', async () => { + mockPerformCreateCredential.mockResolvedValue({ + success: true, + credential: buildRow(), + created: false, + }) + mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'member' }, isAdmin: false }) + + const res = await callCreate(VALID_BODY) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data.credential.role).toBe('member') + }) + + it('reports admin for a fresh insert without a second access lookup', async () => { + const res = await callCreate(VALID_BODY) + + expect((await res.json()).data.credential.role).toBe('admin') + expect(mockGetCredentialActorContext).not.toHaveBeenCalled() + }) + + it('creates the credential and never echoes the submitted secret', async () => { + const res = await callCreate({ + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'zoom-client-id', + clientSecret: 'super-secret-value', + orgId: 'acct_123', + }) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data.credential).toMatchObject({ + id: 'cred_abc123', + hasServiceAccountKey: true, + role: 'admin', + }) + expect(JSON.stringify(body)).not.toContain('super-secret-value') + expect(JSON.stringify(body)).not.toContain('encrypted-blob') + expect(mockPerformCreateCredential).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: WORKSPACE_ID, + userId: 'user-1', + type: 'service_account', + clientSecret: 'super-secret-value', + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/credentials/route.ts b/apps/sim/app/api/v2/credentials/route.ts new file mode 100644 index 00000000000..2b710b275bf --- /dev/null +++ b/apps/sim/app/api/v2/credentials/route.ts @@ -0,0 +1,150 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateCredentialContract, + v2ListCredentialsContract, +} from '@/lib/api/contracts/v2/credentials' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { performCreateCredential } from '@/lib/credentials/orchestration' +import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + toV2Credential, + toV2CredentialRow, + v2CredentialOrchestrationError, +} from '@/app/api/v2/credentials/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2CredentialsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/credentials — List the credentials the caller can see in a workspace. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'credentials') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListCredentialsContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, type, providerId, search, sortBy, sortOrder } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + /** + * Credential visibility is per credential, not per workspace: membership + * rows and shared-type admin access decide what this caller sees, so the + * workspace permission is re-read here for the `canAdmin` bit. + */ + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + const credentials = await listVisibleWorkspaceCredentials({ + workspaceId, + userId, + workspaceAccess, + type, + providerId, + search, + sortBy, + sortOrder, + }) + + // The per-workspace credential set is small and bounded → a single full page. + return v2CursorList(credentials.map(toV2Credential), null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing credentials`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/credentials — Create a workspace credential. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'credentials') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateCredentialContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performCreateCredential({ ...parsed.data.body, userId, request }) + + if (!result.success || !result.credential) { + return v2CredentialOrchestrationError( + result.errorCode, + result.error ?? 'Failed to create credential', + { providerUnavailable: result.providerUnavailable } + ) + } + + /** + * A fresh insert makes the creator an admin, but an idempotent match against + * an existing source does not — the orchestration admits a caller who is + * only a *member* of that credential. Resolve the real role rather than + * assuming the create case, or the response would advertise administrative + * actions the caller cannot perform. + */ + const actor = result.created + ? { isAdmin: true } + : await getCredentialActorContext(result.credential.id, userId) + const credential = toV2CredentialRow(result.credential, actor.isAdmin ? 'admin' : 'member') + + /** + * Always 201, including when an existing credential already occupied this + * source. Create is idempotent on the source tuple, and the caller's + * post-condition — "a credential with this source exists, here it is" — is + * the same either way. + */ + return v2Data({ credential }, { rateLimit, status: 201 }) + } catch (error) { + logger.error(`[${requestId}] Error creating credential`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/credentials/utils.ts b/apps/sim/app/api/v2/credentials/utils.ts new file mode 100644 index 00000000000..b70903da663 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/utils.ts @@ -0,0 +1,75 @@ +import type { NextResponse } from 'next/server' +import type { V2Credential } from '@/lib/api/contracts/v2/credentials' +import type { CredentialOrchestrationErrorCode } from '@/lib/credentials/orchestration' +import type { CredentialRow, VisibleWorkspaceCredential } from '@/lib/credentials/queries' +import { v2Error } from '@/app/api/v2/lib/response' + +/** + * Shared serialization + error mapping for the v2 credentials surface. + * + * Both projections are written field by field on purpose: a credential row + * carries `encryptedServiceAccountKey`, and spreading the row would put it one + * forgotten `omit` away from the wire. + */ + +export function toV2Credential(row: VisibleWorkspaceCredential): V2Credential { + return { + id: row.id, + type: row.type, + displayName: row.displayName, + description: row.description, + providerId: row.providerId, + accountId: row.accountId, + envKey: row.envKey, + hasServiceAccountKey: row.hasServiceAccountKey, + role: row.role, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +/** Projection for a raw credential row, whose caller-role is resolved separately. */ +export function toV2CredentialRow(row: CredentialRow, role: V2Credential['role']): V2Credential { + return { + id: row.id, + type: row.type, + displayName: row.displayName, + description: row.description, + providerId: row.providerId, + accountId: row.accountId, + envKey: row.envKey, + hasServiceAccountKey: Boolean(row.encryptedServiceAccountKey), + role, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +/** + * Renders a credential orchestration failure in the v2 error envelope. + * + * `forbidden` from the orchestration means "not an admin of this credential", + * which is a resource-level denial rather than a workspace one; it stays a 403 + * because the caller already proved workspace access to reach it. + */ +export function v2CredentialOrchestrationError( + errorCode: CredentialOrchestrationErrorCode | undefined, + message: string, + options: { providerUnavailable?: boolean } = {} +): NextResponse { + if (options.providerUnavailable) { + return v2Error('SERVICE_UNAVAILABLE', 'The credential provider is unavailable. Try again.') + } + switch (errorCode) { + case 'validation': + return v2Error('BAD_REQUEST', message) + case 'forbidden': + return v2Error('FORBIDDEN', message) + case 'not_found': + return v2Error('NOT_FOUND', 'Credential not found') + case 'conflict': + return v2Error('CONFLICT', message) + default: + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +} diff --git a/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts b/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts new file mode 100644 index 00000000000..5619a64b1cd --- /dev/null +++ b/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts @@ -0,0 +1,308 @@ +/** + * @vitest-environment node + * + * Public v2 custom tool detail: the per-id get/update/delete the internal + * surface never had, and the rename guard that keeps a duplicate title from + * reaching the unique index. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetWorkspaceCustomTool, + mockGetWorkspaceCustomToolByTitle, + mockDeleteWorkspaceCustomTool, + mockUpdateWorkspaceCustomTool, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetWorkspaceCustomTool: vi.fn(), + mockGetWorkspaceCustomToolByTitle: vi.fn(), + mockDeleteWorkspaceCustomTool: vi.fn(), + mockUpdateWorkspaceCustomTool: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/workflows/custom-tools/operations', () => ({ + getWorkspaceCustomTool: mockGetWorkspaceCustomTool, + getWorkspaceCustomToolByTitle: mockGetWorkspaceCustomToolByTitle, + deleteWorkspaceCustomTool: mockDeleteWorkspaceCustomTool, + updateWorkspaceCustomTool: mockUpdateWorkspaceCustomTool, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { DELETE, GET, PATCH } from '@/app/api/v2/custom-tools/[id]/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +const TOOL_SCHEMA = { + type: 'function', + function: { + name: 'lookup_order', + parameters: { type: 'object', properties: { orderId: { type: 'string' } } }, + }, +} + +function buildTool(overrides: Record = {}) { + return { + id: 'tool_abc123', + workspaceId: 'workspace-1', + userId: 'user-1', + title: 'lookup_order', + schema: TOOL_SCHEMA, + code: 'return { ok: true }', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +const routeContext = () => ({ params: Promise.resolve({ id: 'tool_abc123' }) }) +const url = (query = 'workspaceId=workspace-1') => + `http://localhost:3000/api/v2/custom-tools/tool_abc123?${query}` + +const callGet = (query?: string) => GET(new NextRequest(url(query)), routeContext()) +const callDelete = (query?: string) => + DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext()) + +function callPatch(body: unknown) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/custom-tools/tool_abc123', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + routeContext() + ) +} + +describe('GET /api/v2/custom-tools/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCustomTool.mockResolvedValue(buildTool()) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockGetWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callGet('') + expect(res.status).toBe(400) + expect(mockGetWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callGet() + expect(res.status).toBe(403) + expect(mockGetWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callGet() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the tool is not in the workspace', async () => { + mockGetWorkspaceCustomTool.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('returns the public tool shape without internal scoping columns', async () => { + const res = await callGet() + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.customTool).toEqual({ + id: 'tool_abc123', + title: 'lookup_order', + schema: TOOL_SCHEMA, + code: 'return { ok: true }', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }) + expect(mockGetWorkspaceCustomTool).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + toolId: 'tool_abc123', + }) + }) +}) + +describe('PATCH /api/v2/custom-tools/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCustomTool.mockResolvedValue(buildTool()) + mockGetWorkspaceCustomToolByTitle.mockResolvedValue(null) + mockUpdateWorkspaceCustomTool.mockResolvedValue(buildTool()) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' }) + + expect(res.status).toBe(404) + expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('400s when no field to change is supplied', async () => { + const res = await callPatch({ workspaceId: 'workspace-1' }) + expect(res.status).toBe(400) + expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' }) + expect(res.status).toBe(403) + expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the tool is not in the workspace', async () => { + mockGetWorkspaceCustomTool.mockResolvedValue(null) + const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' }) + expect(res.status).toBe(404) + expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('409s when renaming onto an existing title', async () => { + mockGetWorkspaceCustomToolByTitle.mockResolvedValue(buildTool({ id: 'tool_other' })) + + const res = await callPatch({ workspaceId: 'workspace-1', title: 'taken' }) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('merges the partial body against the stored tool', async () => { + const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 2' }) + + expect(res.status).toBe(200) + expect(mockUpdateWorkspaceCustomTool).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + toolId: 'tool_abc123', + title: 'lookup_order', + schema: TOOL_SCHEMA, + code: 'return 2', + }) + }) + + it('404s rather than orphaning a tool deleted between the read and the write', async () => { + mockUpdateWorkspaceCustomTool.mockResolvedValue(null) + + const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 2' }) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) +}) + +describe('DELETE /api/v2/custom-tools/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCustomTool.mockResolvedValue(buildTool()) + mockDeleteWorkspaceCustomTool.mockResolvedValue(true) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callDelete() + + expect(res.status).toBe(404) + expect(mockDeleteWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callDelete('') + expect(res.status).toBe(400) + expect(mockDeleteWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callDelete() + expect(res.status).toBe(403) + expect(mockDeleteWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callDelete() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the tool is not in the workspace', async () => { + mockGetWorkspaceCustomTool.mockResolvedValue(null) + const res = await callDelete() + expect(res.status).toBe(404) + expect(mockDeleteWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('deletes the tool and acknowledges the id', async () => { + const res = await callDelete() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: { id: 'tool_abc123', deleted: true } }) + expect(mockDeleteWorkspaceCustomTool).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + toolId: 'tool_abc123', + }) + }) +}) diff --git a/apps/sim/app/api/v2/custom-tools/[id]/route.ts b/apps/sim/app/api/v2/custom-tools/[id]/route.ts new file mode 100644 index 00000000000..e793c31c3ea --- /dev/null +++ b/apps/sim/app/api/v2/custom-tools/[id]/route.ts @@ -0,0 +1,197 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteCustomToolContract, + v2GetCustomToolContract, + v2UpdateCustomToolContract, +} from '@/lib/api/contracts/v2/custom-tools' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + deleteWorkspaceCustomTool, + getWorkspaceCustomTool, + getWorkspaceCustomToolByTitle, + updateWorkspaceCustomTool, +} from '@/lib/workflows/custom-tools/operations' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2CustomTool, v2CustomToolWriteError } from '@/app/api/v2/custom-tools/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2CustomToolDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RouteContext { + params: Promise<{ id: string }> +} + +/** GET /api/v2/custom-tools/[id] — Fetch a single custom tool. */ +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'custom-tool-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetCustomToolContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const tool = await getWorkspaceCustomTool({ workspaceId, toolId: id }) + if (!tool) return v2Error('NOT_FOUND', 'Custom tool not found') + + return v2Data({ customTool: toV2CustomTool(tool) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error fetching custom tool`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/custom-tools/[id] — Update a custom tool. Omitted fields keep their values. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'custom-tool-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateCustomToolContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, title, schema, code } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const current = await getWorkspaceCustomTool({ workspaceId, toolId: id }) + if (!current) return v2Error('NOT_FOUND', 'Custom tool not found') + + /** + * `upsertCustomTools` replaces title/schema/code wholesale and checks for a + * duplicate title only when inserting, so a rename onto an existing title + * would hit the `custom_tools_workspace_title_unique` index as a 500. Merge + * the partial body against the stored row and check the rename here. + */ + if (title !== undefined && title !== current.title) { + if (await getWorkspaceCustomToolByTitle({ workspaceId, title })) { + return v2Error( + 'CONFLICT', + `A custom tool titled "${title}" already exists in this workspace` + ) + } + } + + const updated = await updateWorkspaceCustomTool({ + workspaceId, + toolId: id, + title: title ?? current.title, + schema: schema ?? current.schema, + code: code ?? current.code, + }) + if (!updated) return v2Error('NOT_FOUND', 'Custom tool not found') + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.CUSTOM_TOOL_UPDATED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: updated.id, + resourceName: updated.title, + description: `Updated custom tool "${updated.title}" via API`, + request, + }) + + return v2Data({ customTool: toV2CustomTool(updated) }, { rateLimit }) + } catch (error) { + const writeError = v2CustomToolWriteError(error) + if (writeError) return writeError + + logger.error(`[${requestId}] Error updating custom tool`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/custom-tools/[id] — Delete a custom tool. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'custom-tool-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteCustomToolContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const tool = await getWorkspaceCustomTool({ workspaceId, toolId: id }) + if (!tool) return v2Error('NOT_FOUND', 'Custom tool not found') + + const deleted = await deleteWorkspaceCustomTool({ workspaceId, toolId: id }) + if (!deleted) return v2Error('NOT_FOUND', 'Custom tool not found') + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.CUSTOM_TOOL_DELETED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: id, + resourceName: tool.title, + description: `Deleted custom tool "${tool.title}" via API`, + request, + }) + + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting custom tool`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/custom-tools/route.test.ts b/apps/sim/app/api/v2/custom-tools/route.test.ts new file mode 100644 index 00000000000..7ca46e81b0c --- /dev/null +++ b/apps/sim/app/api/v2/custom-tools/route.test.ts @@ -0,0 +1,302 @@ +/** + * @vitest-environment node + * + * Public v2 custom tools list/create: gate ordering, contract validation, and + * the workspace-scoped single-resource create that replaced the bulk upsert. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockListWorkspaceCustomTools, + mockGetWorkspaceCustomToolByTitle, + mockUpsertCustomTools, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockListWorkspaceCustomTools: vi.fn(), + mockGetWorkspaceCustomToolByTitle: vi.fn(), + mockUpsertCustomTools: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/workflows/custom-tools/operations', () => ({ + listWorkspaceCustomTools: mockListWorkspaceCustomTools, + getWorkspaceCustomToolByTitle: mockGetWorkspaceCustomToolByTitle, + upsertCustomTools: mockUpsertCustomTools, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET, POST } from '@/app/api/v2/custom-tools/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const TOOL_SCHEMA = { + type: 'function', + function: { + name: 'lookup_order', + description: 'Look up an order by id', + parameters: { + type: 'object', + properties: { orderId: { type: 'string' } }, + required: ['orderId'], + }, + }, +} + +function buildTool(overrides: Record = {}) { + return { + id: 'tool_abc123', + workspaceId: 'workspace-1', + userId: 'user-1', + title: 'lookup_order', + schema: TOOL_SCHEMA, + code: 'return { ok: true }', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +/** What the route forwards for a bare `?workspaceId=` list. */ +const DEFAULT_LIST_ARGS = { + search: undefined, + sortBy: 'createdAt', + sortOrder: 'desc', +} + +const callList = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/custom-tools?${query}`)) + +function callCreate(body: unknown) { + return POST( + new NextRequest('http://localhost:3000/api/v2/custom-tools', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + +const VALID_BODY = { + workspaceId: 'workspace-1', + title: 'lookup_order', + schema: TOOL_SCHEMA, + code: 'return { ok: true }', +} + +describe('GET /api/v2/custom-tools', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockListWorkspaceCustomTools.mockResolvedValue([buildTool()]) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callList('workspaceId=workspace-1') + + expect(res.status).toBe(404) + expect(mockListWorkspaceCustomTools).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callList('') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockListWorkspaceCustomTools).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(403) + expect(mockListWorkspaceCustomTools).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('returns the public tool shape in the cursor envelope, workspace-scoped', async () => { + const res = await callList('workspaceId=workspace-1') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + id: 'tool_abc123', + title: 'lookup_order', + schema: TOOL_SCHEMA, + code: 'return { ok: true }', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + ]) + expect(mockListWorkspaceCustomTools).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + ...DEFAULT_LIST_ARGS, + }) + }) + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=workspace-1&sortBy=name);--`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=workspace-1&sortOrder=sideways`) + + expect(res.status).toBe(400) + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=workspace-1&search=`) + + expect(res.status).toBe(400) + }) + + it('forwards search and sort into the query and still terminates pagination', async () => { + const res = await callList(`workspaceId=workspace-1&search=report&sortBy=title&sortOrder=asc`) + + expect(res.status).toBe(200) + expect((await res.json()).nextCursor).toBeNull() + }) +}) + +describe('POST /api/v2/custom-tools', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCustomToolByTitle.mockResolvedValue(null) + mockUpsertCustomTools.mockResolvedValue([buildTool()]) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(404) + expect(mockUpsertCustomTools).not.toHaveBeenCalled() + }) + + it('400s when the schema is not an OpenAI function declaration', async () => { + const res = await callCreate({ ...VALID_BODY, schema: { type: 'nonsense' } }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockUpsertCustomTools).not.toHaveBeenCalled() + }) + + it('400s when the body carries an unknown field', async () => { + const res = await callCreate({ ...VALID_BODY, bogus: true }) + expect(res.status).toBe(400) + expect(mockUpsertCustomTools).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(403) + expect(mockUpsertCustomTools).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('409s on a duplicate title instead of hitting the unique index', async () => { + mockGetWorkspaceCustomToolByTitle.mockResolvedValue(buildTool()) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + expect(mockUpsertCustomTools).not.toHaveBeenCalled() + }) + + it('409s when a concurrent create loses the title race inside the lib', async () => { + mockUpsertCustomTools.mockRejectedValue( + new Error('A tool with the title "v2_smoke_tool" already exists in this workspace') + ) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('409s when the unique index rejects the loser of a title race', async () => { + const pgError = Object.assign(new Error('duplicate key value violates unique constraint'), { + code: '23505', + }) + mockUpsertCustomTools.mockRejectedValue(pgError) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('creates the tool and returns 201 with the single tool', async () => { + const res = await callCreate(VALID_BODY) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data.customTool).toMatchObject({ id: 'tool_abc123', title: 'lookup_order' }) + expect(mockUpsertCustomTools).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + tools: [{ title: 'lookup_order', schema: TOOL_SCHEMA, code: 'return { ok: true }' }], + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/custom-tools/route.ts b/apps/sim/app/api/v2/custom-tools/route.ts new file mode 100644 index 00000000000..96b678a5078 --- /dev/null +++ b/apps/sim/app/api/v2/custom-tools/route.ts @@ -0,0 +1,136 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateCustomToolContract, + v2ListCustomToolsContract, +} from '@/lib/api/contracts/v2/custom-tools' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + getWorkspaceCustomToolByTitle, + listWorkspaceCustomTools, + upsertCustomTools, +} from '@/lib/workflows/custom-tools/operations' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2CustomTool, v2CustomToolWriteError } from '@/app/api/v2/custom-tools/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2CustomToolsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/custom-tools — List custom tools in a workspace. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'custom-tools') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListCustomToolsContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, search, sortBy, sortOrder } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const rows = await listWorkspaceCustomTools({ workspaceId, search, sortBy, sortOrder }) + + // The per-workspace tool set is small and bounded → a single full page. + return v2CursorList(rows.map(toV2CustomTool), null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing custom tools`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/custom-tools — Create a custom tool. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'custom-tools') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateCustomToolContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, title, schema, code } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + /** + * Titles are unique per workspace and tools resolve by title at call time, + * so a collision is reported rather than surfacing as a unique-index 500. + */ + if (await getWorkspaceCustomToolByTitle({ workspaceId, title })) { + return v2Error('CONFLICT', `A custom tool titled "${title}" already exists in this workspace`) + } + + const tools = await upsertCustomTools({ + tools: [{ title, schema, code }], + workspaceId, + userId, + requestId, + }) + const created = tools.find((tool) => tool.title === title) + if (!created) return v2Error('INTERNAL_ERROR', 'Internal server error') + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.CUSTOM_TOOL_CREATED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: created.id, + resourceName: created.title, + description: `Created custom tool "${created.title}" via API`, + request, + }) + + return v2Data({ customTool: toV2CustomTool(created) }, { rateLimit, status: 201 }) + } catch (error) { + const writeError = v2CustomToolWriteError(error) + if (writeError) return writeError + + logger.error(`[${requestId}] Error creating custom tool`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/custom-tools/utils.ts b/apps/sim/app/api/v2/custom-tools/utils.ts new file mode 100644 index 00000000000..516101065ad --- /dev/null +++ b/apps/sim/app/api/v2/custom-tools/utils.ts @@ -0,0 +1,46 @@ +import type { customTools } from '@sim/db/schema' +import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' +import type { NextResponse } from 'next/server' +import type { V2CustomTool } from '@/lib/api/contracts/v2/custom-tools' +import { v2Error } from '@/app/api/v2/lib/response' + +/** Shared serialization + error mapping for the v2 custom tool surface. */ + +/** + * Classifies a title collision as a conflict so it surfaces as 409 rather than a + * generic 500. Two distinct failures reach here and both must be covered: + * + * - `upsertCustomTools` throws its own message when its in-transaction duplicate + * `SELECT` finds one. + * - Under a concurrent create or rename, both callers pass that `SELECT` too, and + * the loser is rejected by `custom_tools_workspace_title_unique` as a raw + * Postgres `23505` — whose message matches nothing, which is exactly the race + * the message check alone cannot see. + */ +export function v2CustomToolWriteError(error: unknown): NextResponse | null { + if (getPostgresErrorCode(error) === '23505') { + return v2Error('CONFLICT', 'A custom tool with that title already exists in this workspace') + } + const message = getErrorMessage(error, '') + if (/already exists in this workspace/i.test(message)) { + return v2Error('CONFLICT', message) + } + return null +} + +type CustomToolRow = typeof customTools.$inferSelect + +/** + * Public custom tool projection. `workspaceId` and `userId` are internal + * scoping columns and are not exposed. + */ +export function toV2CustomTool(row: CustomToolRow): V2CustomTool { + return { + id: row.id, + title: row.title, + schema: row.schema as V2CustomTool['schema'], + code: row.code, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts new file mode 100644 index 00000000000..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 new file mode 100644 index 00000000000..e85ccd4b923 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -0,0 +1,181 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteFileContract, + v2DownloadFileContract, + v2RenameFileContract, +} from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { messageForOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { + performDeleteWorkspaceFileItems, + performRenameWorkspaceFile, +} from '@/lib/workspace-files/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2File } from '@/app/api/v2/files/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + rateLimitHeaders, + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface FileRouteParams { + params: Promise<{ fileId: string }> +} + +/** + * GET /api/v2/files/[fileId] — Download file content (binary). + * + * The response carries no JSON envelope, so rate-limit state is surfaced via + * `X-RateLimit-*` headers. Errors still render the canonical v2 JSON error body. + * Lookups are workspace-scoped (IDOR-safe): a file in another workspace 404s. + */ +export const GET = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DownloadFileContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const fileRecord = await getWorkspaceFile(workspaceId, fileId) + if (!fileRecord) return v2Error('NOT_FOUND', 'File not found') + + const buffer = await fetchWorkspaceFileBuffer(fileRecord) + + return new Response(new Uint8Array(buffer), { + status: 200, + headers: { + 'Content-Type': fileRecord.type || 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${fileRecord.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(fileRecord.name)}`, + 'Content-Length': String(buffer.length), + ...rateLimitHeaders(rateLimit), + }, + }) + } catch (error) { + logger.error('Error downloading file', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * PATCH /api/v2/files/[fileId] — Rename a file. + * + * Renaming only; use `POST /api/v2/files/move` to change a file's folder. + * Names that collide within the destination folder are rejected as `CONFLICT` — + * unlike upload, which auto-suffixes on the internal surface. + */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2RenameFileContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId, name } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performRenameWorkspaceFile({ workspaceId, fileId, name, userId }) + + if (!result.success || !result.file) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to rename file') + ) + } + + return v2Data(toV2File(result.file), { rateLimit }) + } catch (error) { + logger.error('Error renaming file', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * DELETE /api/v2/files/[fileId] — 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 gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteFileContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performDeleteWorkspaceFileItems({ + workspaceId, + userId, + fileIds: [fileId], + request, + }) + + if (!result.success) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to delete file') + ) + } + + logger.info(`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/[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..c6e68913959 --- /dev/null +++ b/apps/sim/app/api/v2/files/route.test.ts @@ -0,0 +1,433 @@ +/** + * @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, + mockQueryWorkspaceFiles, + mockUploadWorkspaceFile, + mockGetWorkspaceFile, + mockReadFormDataWithLimit, + mockReadFileToBufferWithLimit, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockQueryWorkspaceFiles: 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', () => ({ + queryWorkspaceFiles: mockQueryWorkspaceFiles, + 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, + } +} + +/** 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}`)) + +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) + mockQueryWorkspaceFiles.mockResolvedValue({ files: [buildRecord()], nextKeys: null }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callList(`workspaceId=${WS}`) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callList('limit=10') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('400s on a scope outside the enum', async () => { + const res = await callList(`workspaceId=${WS}&scope=everything`) + expect(res.status).toBe(400) + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callList(`workspaceId=${WS}`) + expect(res.status).toBe(403) + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callList(`workspaceId=${WS}`) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('returns the public file shape including folder and updatedAt', async () => { + mockQueryWorkspaceFiles.mockResolvedValue({ + files: [buildRecord({ folderId: FOLDER_ID, folderPath: 'Reports/Q1' })], + nextKeys: null, + }) + + const res = await callList(`workspaceId=${WS}`) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + id: 'wf_1', + name: 'data.csv', + size: 1024, + type: 'text/csv', + key: 'workspace/ws/1-x-data.csv', + folderId: FOLDER_ID, + folderPath: 'Reports/Q1', + uploadedBy: 'user-1', + uploadedAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + ]) + expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, DEFAULT_LIST_ARGS) + }) + + it('defaults to the active scope and passes archived through', async () => { + await callList(`workspaceId=${WS}`) + expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, DEFAULT_LIST_ARGS) + + const archived = buildRecord({ id: 'wf_gone', name: 'gone.csv' }) + mockQueryWorkspaceFiles.mockResolvedValue({ files: [archived], nextKeys: null }) + + const res = await callList(`workspaceId=${WS}&scope=archived`) + const body = await res.json() + + 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', () => { + 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 new file mode 100644 index 00000000000..2c088b8b611 --- /dev/null +++ b/apps/sim/app/api/v2/files/route.ts @@ -0,0 +1,231 @@ +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 { + getWorkspaceFile, + 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 { + cursorSortKey, + decodeSortedCursor, + encodeSortedCursor, + v2CaughtOrchestrationError, + v2CursorList, + v2CursorSortError, + 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 + +/** + * 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. + * + * Filtering, ordering, and the page slice all run inside + * {@link queryWorkspaceFiles}' query. The route only translates the validated + * params and the opaque cursor, so a `search` never costs a full-workspace read. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListFilesContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, scope, folderId, search, sortBy, sortOrder, limit, cursor } = + parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + 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 items: V2File[] = files.map(toV2File) + const nextCursor = nextKeys ? encodeSortedCursor(sort, nextKeys) : null + + return v2CursorList(items, nextCursor, { rateLimit }) + } catch (error) { + // A cursor that doesn't fit the requested sort arrives classified as `validation` → 400. + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + logger.error('Error listing files', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * POST /api/v2/files — Upload a file to a workspace. + * + * Authorization runs fully (rate limit → workspace write access) before the + * 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 { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2UploadFileContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, folderId } = 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', + { folderId: folderId ?? null } + ) + + 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, + }) + + /** + * `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(toV2File(fileRecord), { rateLimit, status: 201 }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return v2Error('PAYLOAD_TOO_LARGE', error.message) + } + + // 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/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..4f2622ee903 --- /dev/null +++ b/apps/sim/app/api/v2/folders/route.test.ts @@ -0,0 +1,322 @@ +/** + * @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, + } +} + +/** 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}`)) + +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', + 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', + 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() + }) +}) + +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..ed74572a617 --- /dev/null +++ b/apps/sim/app/api/v2/folders/route.ts @@ -0,0 +1,124 @@ +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, 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, { + search, + sortBy, + sortOrder, + }) + + // 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/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts new file mode 100644 index 00000000000..ef9318b1dc6 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts @@ -0,0 +1,225 @@ +import { db } from '@sim/db' +import { document, knowledgeConnector } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq, isNull } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { + type V2KnowledgeDocument, + v2DeleteKnowledgeDocumentContract, + v2GetKnowledgeDocumentContract, +} from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performDeleteKnowledgeDocument } from '@/lib/knowledge/orchestration' +import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' +import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeDocumentDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface DocumentDetailRouteParams { + params: Promise<{ id: string; documentId: string }> +} + +/** + * Resolves a knowledge base via the shared v1 ownership invariant + * ({@link resolveKnowledgeBase}) and renders any failure in the v2 envelope. A + * `404` is always `NOT_FOUND`; a `403` is masked as `NOT_FOUND` on reads and + * surfaced as `FORBIDDEN` on writes. + */ +async function resolveKnowledgeBaseScoped( + id: string, + workspaceId: string, + userId: string, + rateLimit: RateLimitResult, + level: 'read' | 'write' +): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> { + const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, level) + if (!(result instanceof NextResponse)) return result + if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found') + return level === 'read' + ? v2Error('NOT_FOUND', 'Knowledge base not found') + : v2Error('FORBIDDEN', 'Access denied') +} + +/** GET /api/v2/knowledge/[id]/documents/[documentId] — Get document details. */ +export const GET = withRouteHandler( + async (request: NextRequest, context: DocumentDetailRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetKnowledgeDocumentContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id: knowledgeBaseId, documentId } = parsed.data.params + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + parsed.data.query.workspaceId, + userId, + rateLimit, + 'read' + ) + if (result instanceof NextResponse) return result + + const docs = await db + .select({ + id: document.id, + knowledgeBaseId: document.knowledgeBaseId, + filename: document.filename, + fileSize: document.fileSize, + mimeType: document.mimeType, + processingStatus: document.processingStatus, + processingError: document.processingError, + processingStartedAt: document.processingStartedAt, + processingCompletedAt: document.processingCompletedAt, + chunkCount: document.chunkCount, + tokenCount: document.tokenCount, + characterCount: document.characterCount, + enabled: document.enabled, + uploadedAt: document.uploadedAt, + connectorId: document.connectorId, + connectorType: knowledgeConnector.connectorType, + sourceUrl: document.sourceUrl, + }) + .from(document) + .leftJoin(knowledgeConnector, eq(document.connectorId, knowledgeConnector.id)) + .where( + and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .limit(1) + + const doc = docs[0] + if (!doc) return v2Error('NOT_FOUND', 'Document not found') + + const documentDetail: V2KnowledgeDocument = { + id: doc.id, + knowledgeBaseId: doc.knowledgeBaseId, + filename: doc.filename, + fileSize: doc.fileSize, + mimeType: doc.mimeType, + processingStatus: doc.processingStatus as V2KnowledgeDocument['processingStatus'], + processingError: doc.processingError, + processingStartedAt: serializeDate(doc.processingStartedAt), + processingCompletedAt: serializeDate(doc.processingCompletedAt), + chunkCount: doc.chunkCount, + tokenCount: doc.tokenCount, + characterCount: doc.characterCount, + enabled: doc.enabled, + connectorId: doc.connectorId, + connectorType: doc.connectorType ?? null, + sourceUrl: doc.sourceUrl, + createdAt: serializeDate(doc.uploadedAt), + } + + return v2Data({ document: documentDetail }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error getting document`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +/** DELETE /api/v2/knowledge/[id]/documents/[documentId] — Delete a document. */ +export const DELETE = withRouteHandler( + async (request: NextRequest, context: DocumentDetailRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteKnowledgeDocumentContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id: knowledgeBaseId, documentId } = parsed.data.params + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + parsed.data.query.workspaceId, + userId, + rateLimit, + 'write' + ) + if (result instanceof NextResponse) return result + + const docs = await db + .select({ id: document.id, filename: document.filename }) + .from(document) + .where( + and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .limit(1) + + const doc = docs[0] + if (!doc) return v2Error('NOT_FOUND', 'Document not found') + + const outcome = await performDeleteKnowledgeDocument({ + knowledgeBase: { + id: knowledgeBaseId, + name: result.kb.name, + workspaceId: parsed.data.query.workspaceId, + }, + document: { id: documentId, filename: doc.filename }, + userId, + source: 'api', + requestId, + request, + }) + if (!outcome.success) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error) + } + + return v2Data({ id: documentId, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting document`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts new file mode 100644 index 00000000000..1f513d5f2ed --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -0,0 +1,294 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + type V2KnowledgeDocumentSummary, + v2ListKnowledgeDocumentsContract, + v2UploadKnowledgeDocumentContract, +} from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { + checkAttributedUsageLimits, + resolveBillingAttribution, + resolveSystemBillingAttribution, +} from '@/lib/billing/core/billing-attribution' +import { generateRequestId } from '@/lib/core/utils/request' +import { + isPayloadSizeLimitError, + readFileToBufferWithLimit, + readFormDataWithLimit, +} from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getDocuments } from '@/lib/knowledge/documents/service' +import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' +import { performUploadKnowledgeDocument } from '@/lib/knowledge/orchestration' +import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { validateFileType } from '@/lib/uploads/utils/validation' +import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' +import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeDocumentsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const MAX_FILE_SIZE = 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 gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ListKnowledgeDocumentsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { workspaceId, limit, cursor, search, enabledFilter, sortBy, sortOrder } = + parsed.data.query + const { id: knowledgeBaseId } = parsed.data.params + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + workspaceId, + userId, + rateLimit, + 'read' + ) + if (result instanceof NextResponse) return result + + // Opaque cursor encodes the underlying offset (upgradeable to keyset later). + const offset = cursor ? (decodeCursor<{ offset: number }>(cursor)?.offset ?? 0) : 0 + + const documentsResult = await getDocuments( + knowledgeBaseId, + { + enabledFilter: enabledFilter === 'all' ? undefined : enabledFilter, + search, + limit, + offset, + sortBy: sortBy as DocumentSortField, + sortOrder: sortOrder as SortOrder, + }, + requestId + ) + + const documents: V2KnowledgeDocumentSummary[] = documentsResult.documents.map((doc) => ({ + id: doc.id, + knowledgeBaseId, + filename: doc.filename, + fileSize: doc.fileSize, + mimeType: doc.mimeType, + processingStatus: doc.processingStatus, + chunkCount: doc.chunkCount, + tokenCount: doc.tokenCount, + characterCount: doc.characterCount, + enabled: doc.enabled, + createdAt: serializeDate(doc.uploadedAt), + })) + + const nextCursor = documentsResult.pagination.hasMore + ? encodeCursor({ offset: offset + limit }) + : null + return v2CursorList(documents, nextCursor, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing documents`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * POST /api/v2/knowledge/[id]/documents — Upload a document to a knowledge base. + * + * Authorization runs fully before the multipart body is buffered: the workspace + * is a contract-validated query param (not a form field as in v1), so an + * unauthorized caller never streams a file into memory. Order: rate limit → + * KB ownership (write) → usage gate → buffered multipart read. + */ +export const POST = withRouteHandler( + async (request: NextRequest, context: DocumentsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UploadKnowledgeDocumentContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id: knowledgeBaseId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + workspaceId, + userId, + rateLimit, + 'write' + ) + if (result instanceof NextResponse) return result + + /** + * Gate before storage and indexing. Workspace keys use the billed account + * and immutable payer from one read; personal keys preserve their human actor. + */ + const billingAttribution = + rateLimit.keyType === 'workspace' + ? await resolveSystemBillingAttribution(workspaceId) + : await resolveBillingAttribution({ actorUserId: userId, workspaceId }) + const usage = await checkAttributedUsageLimits(billingAttribution) + if (usage.isExceeded) { + return v2Error( + 'USAGE_LIMIT_EXCEEDED', + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' + ) + } + + let formData: FormData + try { + formData = await readFormDataWithLimit(request, { + maxBytes: MAX_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES, + label: 'knowledge document upload body', + }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return v2Error('PAYLOAD_TOO_LARGE', error.message) + } + return v2Error('BAD_REQUEST', 'Request body must be valid multipart form data') + } + + const rawFile = formData.get('file') + const file = rawFile instanceof File ? rawFile : null + if (!file) { + return v2Error('BAD_REQUEST', 'file form field is required') + } + + if (file.size > MAX_FILE_SIZE) { + return v2Error( + 'PAYLOAD_TOO_LARGE', + `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)` + ) + } + + const fileTypeError = validateFileType(file.name, file.type || '') + if (fileTypeError) { + return v2Error('UNSUPPORTED_MEDIA_TYPE', fileTypeError.message) + } + + const buffer = await readFileToBufferWithLimit(file, { + maxBytes: MAX_FILE_SIZE, + label: 'knowledge document file', + }) + const contentType = file.type || 'application/octet-stream' + + const uploadedFile = await uploadWorkspaceFile( + workspaceId, + userId, + buffer, + file.name, + contentType + ) + + const outcome = await performUploadKnowledgeDocument({ + knowledgeBase: { id: knowledgeBaseId, name: result.kb.name, workspaceId }, + document: { + filename: file.name, + fileUrl: uploadedFile.url, + fileSize: file.size, + mimeType: contentType, + }, + startProcessing: 'queue', + billingAttribution, + uploadedBy: billingAttribution.actorUserId, + userId, + source: 'api', + requestId, + request, + }) + if (!outcome.success) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error) + } + const newDocument = outcome.document + + const document: V2KnowledgeDocumentSummary = { + id: newDocument.id, + knowledgeBaseId, + filename: newDocument.filename, + fileSize: newDocument.fileSize, + mimeType: newDocument.mimeType, + processingStatus: 'pending', + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + enabled: newDocument.enabled, + createdAt: serializeDate(newDocument.uploadedAt), + } + + return v2Data({ document }, { rateLimit, status: 201 }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return v2Error('PAYLOAD_TOO_LARGE', error.message) + } + + logger.error(`[${requestId}] Error uploading document`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/knowledge/[id]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/route.ts new file mode 100644 index 00000000000..65364a2b808 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/route.ts @@ -0,0 +1,191 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + v2DeleteKnowledgeBaseContract, + v2GetKnowledgeBaseContract, + v2UpdateKnowledgeBaseContract, +} from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + 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, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface KnowledgeRouteParams { + params: Promise<{ id: string }> +} + +/** + * Resolves a knowledge base via the shared v1 ownership invariant + * ({@link resolveKnowledgeBase}: workspace access + KB-belongs-to-workspace) and + * renders any failure in the v2 envelope. A `404` (missing KB or workspace + * mismatch) is always `NOT_FOUND`; a `403` (no workspace access) is masked as + * `NOT_FOUND` on reads so cross-workspace KB existence never leaks, and surfaced + * as `FORBIDDEN` on writes. + */ +async function resolveKnowledgeBaseScoped( + id: string, + workspaceId: string, + userId: string, + rateLimit: RateLimitResult, + level: 'read' | 'write' +): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> { + const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, level) + if (!(result instanceof NextResponse)) return result + if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found') + return level === 'read' + ? v2Error('NOT_FOUND', 'Knowledge base not found') + : v2Error('FORBIDDEN', 'Access denied') +} + +/** GET /api/v2/knowledge/[id] — Get knowledge base details. */ +export const GET = withRouteHandler(async (request: NextRequest, context: KnowledgeRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetKnowledgeBaseContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const result = await resolveKnowledgeBaseScoped( + id, + parsed.data.query.workspaceId, + userId, + rateLimit, + 'read' + ) + if (result instanceof NextResponse) return result + + 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 gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateKnowledgeBaseContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, name, description, chunkingConfig } = parsed.data.body + + const result = await resolveKnowledgeBaseScoped(id, workspaceId, userId, rateLimit, 'write') + if (result instanceof NextResponse) return result + + const outcome = await performUpdateKnowledgeBase({ + knowledgeBaseId: id, + workspaceId, + userId, + source: 'api', + updates: { name, description, chunkingConfig }, + requestId, + request, + }) + 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'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/knowledge/[id] — Delete a knowledge base. */ +export const DELETE = withRouteHandler( + async (request: NextRequest, context: KnowledgeRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteKnowledgeBaseContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const result = await resolveKnowledgeBaseScoped( + id, + parsed.data.query.workspaceId, + userId, + rateLimit, + 'write' + ) + if (result instanceof NextResponse) return result + + const outcome = await performDeleteKnowledgeBase({ + knowledgeBase: { id, name: result.kb.name, workspaceId: parsed.data.query.workspaceId }, + userId, + source: 'api', + requestId, + request, + }) + if (!outcome.success) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error) + } + + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting knowledge base`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/knowledge/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 new file mode 100644 index 00000000000..01811a343f5 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -0,0 +1,129 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateKnowledgeBaseContract, + v2ListKnowledgeBasesContract, +} from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { 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' +import { + v2CursorList, + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/knowledge — List knowledge bases in a workspace. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListKnowledgeBasesContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, 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, 'active', { + folderId, + search, + sortBy, + sortOrder, + }) + 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 gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateKnowledgeBaseContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, name, description, chunkingConfig } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const outcome = await performCreateKnowledgeBase({ + userId, + source: 'api', + workspaceId, + name, + description, + chunkingConfig, + requestId, + request, + }) + 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'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts new file mode 100644 index 00000000000..005edb3b919 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -0,0 +1,319 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + type V2KnowledgeSearchResult, + v2SearchKnowledgeContract, +} from '@/lib/api/contracts/v2/knowledge' +import { isZodError, parseRequest } from '@/lib/api/server' +import { + checkAttributedUsageLimits, + resolveBillingAttribution, + resolveSystemBillingAttribution, +} from '@/lib/billing/core/billing-attribution' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' +import { recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' +import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' +import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' +import type { StructuredFilter } from '@/lib/knowledge/types' +import { + generateSearchEmbedding, + getDocumentMetadataByIds, + getQueryStrategy, + handleTagAndVectorSearch, + handleTagOnlySearch, + handleVectorOnlySearch, + type SearchResult, +} from '@/app/api/knowledge/search/utils' +import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeSearchAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** POST /api/v2/knowledge/search — Vector / tag search across knowledge bases. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-search') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2SearchKnowledgeContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, topK, query, tagFilters } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + /** + * A query incurs hosted embedding (+ optional rerank) cost — gate the + * actor's usage before spending; tag-only search is free. Workspace keys + * resolve their system actor and immutable payer from one workspace read. + */ + const hasBillableQuery = Boolean(query?.trim()) + const billingAttribution = hasBillableQuery + ? rateLimit.keyType === 'workspace' + ? await resolveSystemBillingAttribution(workspaceId) + : await resolveBillingAttribution({ actorUserId: userId, workspaceId }) + : undefined + const billingActorUserId = billingAttribution?.actorUserId ?? userId + if (billingAttribution) { + const usage = await checkAttributedUsageLimits(billingAttribution) + if (usage.isExceeded) { + return v2Error( + 'USAGE_LIMIT_EXCEEDED', + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' + ) + } + } + + const knowledgeBaseIds = Array.isArray(parsed.data.body.knowledgeBaseIds) + ? parsed.data.body.knowledgeBaseIds + : [parsed.data.body.knowledgeBaseIds] + + const accessChecks = await Promise.all( + knowledgeBaseIds.map((kbId) => checkKnowledgeBaseAccess(kbId, userId)) + ) + const accessibleKbs = accessChecks + .filter( + (ac): ac is KnowledgeBaseAccessResult => + ac.hasAccess === true && ac.knowledgeBase.workspaceId === workspaceId + ) + .map((ac) => ac.knowledgeBase) + const accessibleKbIds = accessibleKbs.map((kb) => kb.id) + + if (accessibleKbIds.length === 0) { + return v2Error('NOT_FOUND', 'Knowledge base not found or access denied') + } + + const inaccessibleKbIds = knowledgeBaseIds.filter((id) => !accessibleKbIds.includes(id)) + if (inaccessibleKbIds.length > 0) { + return v2Error( + 'NOT_FOUND', + `Knowledge bases not found or access denied: ${inaccessibleKbIds.join(', ')}` + ) + } + + let structuredFilters: StructuredFilter[] = [] + const tagDefsCache = new Map>>() + + if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 1) { + return v2Error( + 'BAD_REQUEST', + 'Tag filters are only supported when searching a single knowledge base' + ) + } + + if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 0) { + const kbId = accessibleKbIds[0] + const tagDefs = await getDocumentTagDefinitions(kbId) + tagDefsCache.set(kbId, tagDefs) + + const displayNameToTagDef: Record = {} + tagDefs.forEach((def) => { + displayNameToTagDef[def.displayName] = { + tagSlot: def.tagSlot, + fieldType: def.fieldType, + } + }) + + const undefinedTags: string[] = [] + const typeErrors: string[] = [] + + for (const filter of tagFilters) { + const tagDef = displayNameToTagDef[filter.tagName] + if (!tagDef) { + undefinedTags.push(filter.tagName) + continue + } + const validationError = validateTagValue( + filter.tagName, + String(filter.value), + tagDef.fieldType + ) + if (validationError) { + typeErrors.push(validationError) + } + } + + if (undefinedTags.length > 0 || typeErrors.length > 0) { + const errorParts: string[] = [] + if (undefinedTags.length > 0) { + errorParts.push(buildUndefinedTagsError(undefinedTags)) + } + if (typeErrors.length > 0) { + errorParts.push(...typeErrors) + } + return v2Error('BAD_REQUEST', errorParts.join('\n')) + } + + structuredFilters = tagFilters.map((filter) => { + const tagDef = displayNameToTagDef[filter.tagName]! + return { + tagSlot: tagDef.tagSlot, + fieldType: tagDef.fieldType, + operator: filter.operator, + value: filter.value, + valueTo: filter.valueTo, + } + }) + } + + const hasQuery = Boolean(query && query.trim().length > 0) + const hasFilters = structuredFilters.length > 0 + + const embeddingModels = Array.from(new Set(accessibleKbs.map((kb) => kb.embeddingModel))) + if (hasQuery && embeddingModels.length > 1) { + return v2Error( + 'BAD_REQUEST', + 'Selected knowledge bases use different embedding models and cannot be searched together. Search them separately.' + ) + } + const queryEmbeddingModel = embeddingModels[0] + + let results: SearchResult[] + let queryEmbeddingIsBYOK: boolean | null = null + + if (!hasQuery && hasFilters) { + results = await handleTagOnlySearch({ + knowledgeBaseIds: accessibleKbIds, + topK, + structuredFilters, + }) + } else if (hasQuery && hasFilters) { + const strategy = getQueryStrategy(accessibleKbIds.length, topK) + const queryEmbeddingResult = await generateSearchEmbedding( + query!, + queryEmbeddingModel, + workspaceId + ) + queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK + const queryVector = JSON.stringify(queryEmbeddingResult.embedding) + results = await handleTagAndVectorSearch({ + knowledgeBaseIds: accessibleKbIds, + topK, + structuredFilters, + queryVector, + distanceThreshold: strategy.distanceThreshold, + }) + } else if (hasQuery) { + const strategy = getQueryStrategy(accessibleKbIds.length, topK) + const queryEmbeddingResult = await generateSearchEmbedding( + query!, + queryEmbeddingModel, + workspaceId + ) + queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK + const queryVector = JSON.stringify(queryEmbeddingResult.embedding) + results = await handleVectorOnlySearch({ + knowledgeBaseIds: accessibleKbIds, + topK, + queryVector, + distanceThreshold: strategy.distanceThreshold, + }) + } else { + return v2Error('BAD_REQUEST', 'Either query or tagFilters must be provided') + } + + if (queryEmbeddingIsBYOK !== null) { + await recordSearchEmbeddingUsage({ + userId: billingActorUserId, + workspaceId, + embeddingModel: queryEmbeddingModel, + query: query!, + isBYOK: queryEmbeddingIsBYOK, + sourceReference: `v2-kb-search:${requestId}`, + billingAttribution, + }) + } + + const tagDefsResults = await Promise.all( + accessibleKbIds.map(async (kbId) => { + try { + const tagDefs = tagDefsCache.get(kbId) ?? (await getDocumentTagDefinitions(kbId)) + const map: Record = {} + tagDefs.forEach((def) => { + map[def.tagSlot] = def.displayName + }) + return { kbId, map } + } catch { + return { kbId, map: {} as Record } + } + }) + ) + const tagDefinitionsMap: Record> = {} + tagDefsResults.forEach(({ kbId, map }) => { + tagDefinitionsMap[kbId] = map + }) + + const documentIds = results.map((r) => r.documentId) + const documentMetadataMap = await getDocumentMetadataByIds(documentIds) + + const searchResults: V2KnowledgeSearchResult[] = results.map((result) => { + const kbTagMap = tagDefinitionsMap[result.knowledgeBaseId] || {} + const metadata: Record = {} + + ALL_TAG_SLOTS.forEach((slot) => { + const tagValue = result[slot as keyof SearchResult] + if (tagValue !== null && tagValue !== undefined) { + const displayName = kbTagMap[slot] || slot + metadata[displayName] = tagValue + } + }) + + const docMeta = documentMetadataMap[result.documentId] + return { + documentId: result.documentId, + documentName: docMeta?.filename ?? null, + sourceUrl: docMeta?.sourceUrl ?? null, + content: result.content, + chunkIndex: result.chunkIndex, + metadata, + similarity: hasQuery ? 1 - result.distance : 1, + } + }) + + return v2Data( + { + results: searchResults, + query: query || '', + knowledgeBaseIds: accessibleKbIds, + topK, + totalResults: results.length, + }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + logger.error(`[${requestId}] Knowledge search error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/lib/gate.ts b/apps/sim/app/api/v2/lib/gate.ts new file mode 100644 index 00000000000..d9bf214eece --- /dev/null +++ b/apps/sim/app/api/v2/lib/gate.ts @@ -0,0 +1,23 @@ +import type { NextResponse } from 'next/server' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { v2Error } from '@/app/api/v2/lib/response' + +/** + * Rollout gate for the entire `/api/v2` surface. + * + * Exactly one check per request, placed immediately after the route + * authenticates and before it does any work. When the flag is off the route + * answers 404 as if it did not exist, so an ungated caller cannot distinguish + * "not in the rollout cohort" from "no such endpoint". + * + * Deliberately keyed on `userId` only. A workspace- or org-keyed gate would + * have to read membership for a caller-supplied id before authorization has + * run, and its 404-vs-403 split would then leak whether that workspace's org + * is in the cohort — the trap the per-domain table gate has to work around by + * running late. Keyed on the authenticated user, the check is safe to run + * first and is uniform across every v2 route. + */ +export async function v2ApiGateError(userId: string): Promise { + if (await isFeatureEnabled('v2-api', { userId })) return null + return v2Error('NOT_FOUND', 'Not found') +} diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts new file mode 100644 index 00000000000..f226e77a345 --- /dev/null +++ b/apps/sim/app/api/v2/lib/response.ts @@ -0,0 +1,248 @@ +import { NextResponse } from 'next/server' +import type { ZodError } from 'zod' +import { type CursorKey, INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' +import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server' +import { asOrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import type { RateLimitResult, WorkspaceAccessError } from '@/app/api/v1/middleware' + +/** + * Runtime response helpers for the v2 API surface. Every v2 route renders its + * output through these so the envelope, error shape, and rate-limit headers stay + * identical across the whole surface. v2 routes reuse the v1 auth/rate-limit + * middleware and the platform domain services — these helpers only standardize + * the HTTP envelope. + */ + +export type V2ErrorCode = + | 'BAD_REQUEST' + | 'UNAUTHORIZED' + | 'FORBIDDEN' + | 'NOT_FOUND' + | 'CONFLICT' + | 'PAYLOAD_TOO_LARGE' + | 'UNSUPPORTED_MEDIA_TYPE' + | 'USAGE_LIMIT_EXCEEDED' + | 'LOCKED' + | 'RATE_LIMITED' + | 'CLIENT_CLOSED_REQUEST' + | 'INTERNAL_ERROR' + | 'SERVICE_UNAVAILABLE' + +const STATUS_BY_CODE: Record = { + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + USAGE_LIMIT_EXCEEDED: 402, + FORBIDDEN: 403, + NOT_FOUND: 404, + CONFLICT: 409, + PAYLOAD_TOO_LARGE: 413, + UNSUPPORTED_MEDIA_TYPE: 415, + LOCKED: 423, + RATE_LIMITED: 429, + CLIENT_CLOSED_REQUEST: 499, + INTERNAL_ERROR: 500, + SERVICE_UNAVAILABLE: 503, +} + +/** + * Every v2 response is authed, per-caller data (ids/filters appear in query + * strings) — keep it out of shared HTTP caches unconditionally. + */ +const PRIVATE_NO_STORE = { 'Cache-Control': 'private, no-store' } as const + +type RateLimitHeaderSource = Pick + +export function rateLimitHeaders(rateLimit?: RateLimitHeaderSource): Record { + if (!rateLimit) return {} + return { + 'X-RateLimit-Limit': rateLimit.limit.toString(), + 'X-RateLimit-Remaining': rateLimit.remaining.toString(), + 'X-RateLimit-Reset': rateLimit.resetAt.toISOString(), + } +} + +interface V2SuccessOptions { + rateLimit?: RateLimitHeaderSource + status?: number + headers?: Record +} + +function successHeaders(options: V2SuccessOptions): Record { + return { ...PRIVATE_NO_STORE, ...rateLimitHeaders(options.rateLimit), ...options.headers } +} + +/** `{ data }` (+ rate-limit headers). */ +export function v2Data(data: T, options: V2SuccessOptions = {}): NextResponse { + return NextResponse.json( + { data }, + { status: options.status ?? 200, headers: successHeaders(options) } + ) +} + +/** `{ data, nextCursor }` (+ rate-limit headers). */ +export function v2CursorList( + data: T[], + nextCursor: string | null, + options: V2SuccessOptions = {} +): NextResponse { + return NextResponse.json( + { data, nextCursor }, + { status: options.status ?? 200, headers: successHeaders(options) } + ) +} + +interface V2ErrorOptions { + status?: number + details?: unknown + headers?: Record +} + +/** `{ error: { code, message, details? } }`. */ +export function v2Error( + code: V2ErrorCode, + message: string, + options: V2ErrorOptions = {} +): NextResponse { + const error: { code: V2ErrorCode; message: string; details?: unknown } = { code, message } + if (options.details !== undefined) error.details = options.details + return NextResponse.json( + { error }, + { + status: options.status ?? STATUS_BY_CODE[code], + headers: { ...PRIVATE_NO_STORE, ...options.headers }, + } + ) +} + +/** Render a contract `ZodError` as the v2 error envelope. */ +export function v2ValidationError(error: ZodError): NextResponse { + return v2Error('BAD_REQUEST', getValidationErrorMessage(error, 'Invalid request'), { + details: serializeZodIssues(error), + }) +} + +/** Render a shared {@link WorkspaceAccessError} as the v2 error envelope. */ +export function v2WorkspaceAccessError(failure: WorkspaceAccessError): NextResponse { + return v2Error(failure.code, failure.message, { status: failure.status }) +} + +/** + * Render a v1 rate-limit/auth failure (`checkRateLimit` result) as the v2 error + * envelope: an auth failure becomes 401, a throttle becomes 429 with + * `Retry-After`. + */ +export function v2RateLimitError(rateLimit: RateLimitResult): NextResponse { + const headers = rateLimitHeaders(rateLimit) + if (rateLimit.error) { + return v2Error('UNAUTHORIZED', rateLimit.error, { headers }) + } + const retryAfterSeconds = rateLimit.retryAfterMs + ? Math.ceil(rateLimit.retryAfterMs / 1000) + : Math.ceil((rateLimit.resetAt.getTime() - Date.now()) / 1000) + return v2Error('RATE_LIMITED', 'API rate limit exceeded', { + headers: { ...headers, 'Retry-After': retryAfterSeconds.toString() }, + details: { retryAfter: rateLimit.resetAt.toISOString() }, + }) +} + +/** Opaque base64-JSON keyset cursor codec shared by all v2 cursor lists. */ +export function encodeCursor(data: Record): string { + return Buffer.from(JSON.stringify(data)).toString('base64') +} + +export function decodeCursor>(cursor: string): T | null { + try { + return JSON.parse(Buffer.from(cursor, 'base64').toString()) as T + } catch { + return null + } +} + +/** + * The sort a keyset cursor was minted under, as it is written into the cursor + * payload. Comparing the whole string is what makes a mid-pagination sort + * change detectable. + */ +export function cursorSortKey(sortBy: string, sortOrder: string): string { + return `${sortBy}:${sortOrder}` +} + +interface SortedCursorPayload { + sort: string + keys: CursorKey[] +} + +/** + * A keyset cursor stamped with the sort that produced it. The keys are only + * meaningful under that exact ordering, so the stamp travels with them. + */ +export function encodeSortedCursor(sort: string, keys: CursorKey[]): string { + return encodeCursor({ sort, keys } satisfies SortedCursorPayload) +} + +export type DecodedSortedCursor = + | { status: 'absent' } + | { status: 'ok'; keys: CursorKey[] } + /** Malformed, or minted under a different sort — the page cannot be resumed. */ + | { status: 'invalid' } + +/** + * Reads a keyset cursor back, refusing one that does not belong to the + * requested sort. Resuming a `name`-ordered cursor under `createdAt` would + * compare the wrong column and silently duplicate or skip rows, so a mismatch + * is a client error rather than a best-effort page. A cursor that isn't valid + * base64-JSON is rejected for the same reason: ignoring it would restart from + * page one while the caller believes it is paging forward. + * + * This checks the envelope only. The key VALUES are caller-controlled too, and + * are type-checked against the sort's keys by `keysetAfter`, which is where a + * bad arity or an unparseable timestamp is caught. + */ +export function decodeSortedCursor(cursor: string | undefined, sort: string): DecodedSortedCursor { + if (!cursor) return { status: 'absent' } + const decoded = decodeCursor>(cursor) + if (!decoded || decoded.sort !== sort || !Array.isArray(decoded.keys)) { + return { status: 'invalid' } + } + return { status: 'ok', keys: decoded.keys } +} + +/** The 400 for a cursor that cannot be resumed under the request's sort. */ +export function v2CursorSortError(): NextResponse { + return v2Error('BAD_REQUEST', INVALID_CURSOR_MESSAGE) +} + +const V2_CODE_BY_ORCHESTRATION_ERROR: Record = { + validation: 'BAD_REQUEST', + unauthorized: 'UNAUTHORIZED', + forbidden: 'FORBIDDEN', + not_found: 'NOT_FOUND', + conflict: 'CONFLICT', + locked: 'LOCKED', + payload_too_large: 'PAYLOAD_TOO_LARGE', + internal: 'INTERNAL_ERROR', +} + +/** + * Renders a `lib/[resource]/orchestration` failure in the v2 envelope, so every + * v2 route maps a given failure class to the same status without restating the + * mapping. Mirrors `statusForOrchestrationError` for the v1/UI surfaces. + */ +export function v2ErrorForOrchestration( + code: OrchestrationErrorCode | undefined, + message: string +): 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/logs/[id]/route.ts b/apps/sim/app/api/v2/logs/[id]/route.ts new file mode 100644 index 00000000000..02593d73ec9 --- /dev/null +++ b/apps/sim/app/api/v2/logs/[id]/route.ts @@ -0,0 +1,114 @@ +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 { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2LogDetailAPI') + +export const revalidate = 0 + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'logs-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetLogContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const rows = await db + .select({ + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, + executionId: workflowExecutionLogs.executionId, + level: workflowExecutionLogs.level, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + executionData: workflowExecutionLogs.executionData, + costTotal: workflowExecutionLogs.costTotal, + files: workflowExecutionLogs.files, + createdAt: workflowExecutionLogs.createdAt, + workflowName: workflow.name, + workflowDescription: workflow.description, + workflowFolderId: workflow.folderId, + workflowUserId: workflow.userId, + workflowWorkspaceId: workflow.workspaceId, + workflowCreatedAt: workflow.createdAt, + workflowUpdatedAt: workflow.updatedAt, + }) + .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..5b811960412 --- /dev/null +++ b/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts @@ -0,0 +1,79 @@ +import { db } from '@sim/db' +import { workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2Execution, v2GetExecutionContract } from '@/lib/api/contracts/v2/logs' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2ExecutionAPI') + +export const revalidate = 0 + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ executionId: string }> }) => { + try { + const rateLimit = await checkRateLimit(request, 'logs-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetExecutionContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { executionId } = parsed.data.params + + const rows = await db + .select() + .from(workflowExecutionLogs) + .where(eq(workflowExecutionLogs.executionId, executionId)) + .limit(1) + + if (rows.length === 0) return v2Error('NOT_FOUND', 'Workflow execution not found') + + const workflowLog = rows[0] + + // Convert an authorization failure into 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, workflowLog.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Workflow execution not found') + + const [snapshot] = await db + .select() + .from(workflowExecutionSnapshots) + .where(eq(workflowExecutionSnapshots.id, workflowLog.stateSnapshotId)) + .limit(1) + + if (!snapshot) return v2Error('NOT_FOUND', 'Workflow state snapshot not found') + + const execution: V2Execution = { + executionId, + workflowId: workflowLog.workflowId, + workflowState: snapshot.stateData, + executionMetadata: { + trigger: workflowLog.trigger, + startedAt: workflowLog.startedAt.toISOString(), + endedAt: workflowLog.endedAt ? workflowLog.endedAt.toISOString() : null, + totalDurationMs: workflowLog.totalDurationMs, + cost: workflowLog.costTotal != null ? { total: Number(workflowLog.costTotal) } : null, + }, + } + + return v2Data(execution, { rateLimit }) + } catch (error) { + logger.error('Error fetching execution data', { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts new file mode 100644 index 00000000000..c4e4077bdc5 --- /dev/null +++ b/apps/sim/app/api/v2/logs/route.ts @@ -0,0 +1,173 @@ +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 { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2LogsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'logs') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListLogsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const params = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const 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/mcp-servers/[id]/route.test.ts b/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts new file mode 100644 index 00000000000..1d5b93a53de --- /dev/null +++ b/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts @@ -0,0 +1,350 @@ +/** + * @vitest-environment node + * + * Public v2 MCP server detail: gate ordering, contract validation, workspace + * access, and the thin-wrapper mapping onto `lib/mcp/orchestration`. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { McpServerRow } from '@/lib/mcp/queries' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetWorkspaceMcpServer, + mockPerformUpdateMcpServer, + mockPerformDeleteMcpServer, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetWorkspaceMcpServer: vi.fn(), + mockPerformUpdateMcpServer: vi.fn(), + mockPerformDeleteMcpServer: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/mcp/queries', () => ({ + getWorkspaceMcpServer: mockGetWorkspaceMcpServer, +})) + +vi.mock('@/lib/mcp/orchestration', () => ({ + performUpdateMcpServer: mockPerformUpdateMcpServer, + performDeleteMcpServer: mockPerformDeleteMcpServer, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { DELETE, GET, PATCH } from '@/app/api/v2/mcp-servers/[id]/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +function buildRow(overrides: Partial = {}): McpServerRow { + return { + id: 'mcp-abc12345', + workspaceId: 'workspace-1', + createdBy: 'user-1', + name: 'Docs server', + description: null, + transport: 'streamable-http', + url: 'https://mcp.example.com/sse', + authType: 'headers', + oauthClientId: null, + oauthClientSecret: 'encrypted-secret', + headers: { Authorization: 'Bearer super-secret-token' }, + timeout: 30000, + retries: 3, + enabled: true, + lastConnected: null, + connectionStatus: 'disconnected', + lastError: null, + statusConfig: {}, + toolCount: 0, + lastToolsRefresh: null, + totalRequests: 0, + lastUsed: null, + deletedAt: null, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } as McpServerRow +} + +const routeContext = () => ({ params: Promise.resolve({ id: 'mcp-abc12345' }) }) + +const url = (query = 'workspaceId=workspace-1') => + `http://localhost:3000/api/v2/mcp-servers/mcp-abc12345?${query}` + +function callGet(query?: string) { + return GET(new NextRequest(url(query)), routeContext()) +} + +function callPatch(body: unknown) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/mcp-servers/mcp-abc12345', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + routeContext() + ) +} + +function callDelete(query?: string) { + return DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext()) +} + +describe('GET /api/v2/mcp-servers/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceMcpServer.mockResolvedValue(buildRow()) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callGet() + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockGetWorkspaceMcpServer).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callGet('') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockGetWorkspaceMcpServer).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callGet() + expect(res.status).toBe(403) + expect(mockGetWorkspaceMcpServer).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callGet() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the server does not exist in the workspace', async () => { + mockGetWorkspaceMcpServer.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('returns the public server shape without header values', async () => { + const res = await callGet() + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.mcpServer).toMatchObject({ + id: 'mcp-abc12345', + hasHeaders: true, + headerNames: ['Authorization'], + hasOauthClientSecret: true, + }) + expect(JSON.stringify(body)).not.toContain('super-secret-token') + expect(JSON.stringify(body)).not.toContain('encrypted-secret') + expect(mockGetWorkspaceMcpServer).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + serverId: 'mcp-abc12345', + }) + }) +}) + +describe('PATCH /api/v2/mcp-servers/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformUpdateMcpServer.mockResolvedValue({ success: true, server: buildRow() }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed' }) + + expect(res.status).toBe(404) + expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled() + }) + + it('400s when the body has an unknown field', async () => { + const res = await callPatch({ workspaceId: 'workspace-1', bogus: true }) + expect(res.status).toBe(400) + expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled() + }) + + it('400s when the url carries an environment-variable template', async () => { + const res = await callPatch({ workspaceId: 'workspace-1', url: 'https://{{HOST}}/sse' }) + expect(res.status).toBe(400) + expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed' }) + expect(res.status).toBe(403) + expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed' }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('maps a not_found orchestration failure to 404', async () => { + mockPerformUpdateMcpServer.mockResolvedValue({ + success: false, + error: 'Server not found', + errorCode: 'not_found', + }) + const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed' }) + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('400s when the url is changed, since the id is derived from it', async () => { + mockGetWorkspaceMcpServer.mockResolvedValue(buildRow()) + + const res = await callPatch({ + workspaceId: 'workspace-1', + url: 'https://different.example.com/sse', + }) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('url cannot be changed') + expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled() + }) + + it('allows a url that matches the stored one, so a full-object PATCH still works', async () => { + mockGetWorkspaceMcpServer.mockResolvedValue(buildRow()) + + const res = await callPatch({ + workspaceId: 'workspace-1', + url: 'https://mcp.example.com/sse', + enabled: false, + }) + + expect(res.status).toBe(200) + expect(mockPerformUpdateMcpServer).toHaveBeenCalled() + }) + + it('updates the server and returns the public shape', async () => { + const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed', enabled: false }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.mcpServer.id).toBe('mcp-abc12345') + expect(body.data.mcpServer.headers).toBeUndefined() + expect(mockPerformUpdateMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + serverId: 'mcp-abc12345', + name: 'Renamed', + enabled: false, + }) + ) + }) +}) + +describe('DELETE /api/v2/mcp-servers/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformDeleteMcpServer.mockResolvedValue({ success: true, server: buildRow() }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callDelete() + + expect(res.status).toBe(404) + expect(mockPerformDeleteMcpServer).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callDelete('') + expect(res.status).toBe(400) + expect(mockPerformDeleteMcpServer).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callDelete() + expect(res.status).toBe(403) + expect(mockPerformDeleteMcpServer).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callDelete() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('maps a not_found orchestration failure to 404', async () => { + mockPerformDeleteMcpServer.mockResolvedValue({ + success: false, + error: 'Server not found', + errorCode: 'not_found', + }) + const res = await callDelete() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('deletes the server and acknowledges the id', async () => { + const res = await callDelete() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: { id: 'mcp-abc12345', deleted: true } }) + expect(mockPerformDeleteMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + serverId: 'mcp-abc12345', + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/route.ts b/apps/sim/app/api/v2/mcp-servers/[id]/route.ts new file mode 100644 index 00000000000..22231ef8eba --- /dev/null +++ b/apps/sim/app/api/v2/mcp-servers/[id]/route.ts @@ -0,0 +1,181 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteMcpServerContract, + v2GetMcpServerContract, + v2UpdateMcpServerContract, +} from '@/lib/api/contracts/v2/mcp-servers' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performDeleteMcpServer, performUpdateMcpServer } from '@/lib/mcp/orchestration' +import { getWorkspaceMcpServer } from '@/lib/mcp/queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toV2McpServer, v2McpOrchestrationError } from '@/app/api/v2/mcp-servers/utils' + +const logger = createLogger('V2McpServerDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RouteContext { + params: Promise<{ id: string }> +} + +/** GET /api/v2/mcp-servers/[id] — Fetch a single MCP server. */ +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'mcp-server-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetMcpServerContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const server = await getWorkspaceMcpServer({ workspaceId, serverId: id }) + if (!server) return v2Error('NOT_FOUND', 'MCP server not found') + + return v2Data({ mcpServer: toV2McpServer(server) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error fetching MCP server`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/mcp-servers/[id] — Update an MCP server's configuration. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'mcp-server-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateMcpServerContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, ...body } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + /** + * A server's id is the hash of its workspace + URL, and this surface promises + * that identity. The lib will happily move `url` while the id keeps hashing + * the old one, which both breaks that promise and defeats the duplicate + * check on create (id-keyed, so it would not see the moved URL) — leaving two + * rows on one URL. Re-pointing a server at a different URL is a new server. + */ + if (body.url !== undefined) { + const current = await getWorkspaceMcpServer({ workspaceId, serverId: id }) + if (!current) return v2Error('NOT_FOUND', 'MCP server not found') + if (current.url !== body.url) { + return v2Error( + 'BAD_REQUEST', + 'url cannot be changed: an MCP server’s id is derived from its URL. Delete this server and create one at the new URL.' + ) + } + } + + const result = await performUpdateMcpServer({ + workspaceId, + userId, + serverId: id, + name: body.name, + description: body.description, + transport: body.transport, + url: body.url, + headers: body.headers, + timeout: body.timeout, + retries: body.retries, + enabled: body.enabled, + authType: body.authType, + oauthClientId: body.oauthClientId ?? null, + oauthClientIdProvided: body.oauthClientId !== undefined, + oauthClientSecret: body.oauthClientSecret, + oauthClientSecretProvided: body.oauthClientSecret !== undefined, + request, + }) + + if (!result.success || !result.server) { + return v2McpOrchestrationError(result.errorCode, result.error ?? 'Failed to update server') + } + + return v2Data({ mcpServer: toV2McpServer(result.server) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error updating MCP server`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/mcp-servers/[id] — Remove an MCP server from the workspace. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'mcp-server-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteMcpServerContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performDeleteMcpServer({ workspaceId, userId, serverId: id, request }) + if (!result.success) { + return v2McpOrchestrationError(result.errorCode, result.error ?? 'Failed to delete server') + } + + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting MCP server`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/mcp-servers/route.test.ts b/apps/sim/app/api/v2/mcp-servers/route.test.ts new file mode 100644 index 00000000000..cb0df3ac683 --- /dev/null +++ b/apps/sim/app/api/v2/mcp-servers/route.test.ts @@ -0,0 +1,365 @@ +/** + * @vitest-environment node + * + * Public v2 MCP servers list/create: gate ordering, contract validation, the + * write-only `headers` projection, and the 409-on-duplicate-URL departure from + * the internal upsert. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { McpServerRow } from '@/lib/mcp/queries' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockListWorkspaceMcpServers, + mockGetWorkspaceMcpServer, + mockGetMcpServerIdState, + mockPerformCreateMcpServer, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockListWorkspaceMcpServers: vi.fn(), + mockGetWorkspaceMcpServer: vi.fn(), + mockGetMcpServerIdState: vi.fn(), + mockPerformCreateMcpServer: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/mcp/queries', () => ({ + listWorkspaceMcpServers: mockListWorkspaceMcpServers, + getWorkspaceMcpServer: mockGetWorkspaceMcpServer, + getMcpServerIdState: mockGetMcpServerIdState, +})) + +vi.mock('@/lib/mcp/orchestration', () => ({ + performCreateMcpServer: mockPerformCreateMcpServer, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET, POST } from '@/app/api/v2/mcp-servers/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +function buildRow(overrides: Partial = {}): McpServerRow { + return { + id: 'mcp-abc12345', + workspaceId: 'workspace-1', + createdBy: 'user-1', + name: 'Docs server', + description: 'Internal docs', + transport: 'streamable-http', + url: 'https://mcp.example.com/sse', + authType: 'headers', + oauthClientId: null, + oauthClientSecret: null, + headers: { Authorization: 'Bearer super-secret-token' }, + timeout: 30000, + retries: 3, + enabled: true, + lastConnected: new Date('2024-01-02T00:00:00Z'), + connectionStatus: 'connected', + lastError: null, + statusConfig: {}, + toolCount: 4, + lastToolsRefresh: new Date('2024-01-02T00:00:00Z'), + totalRequests: 0, + lastUsed: null, + deletedAt: null, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } as McpServerRow +} + +function callList(query: string) { + return GET(new NextRequest(`http://localhost:3000/api/v2/mcp-servers?${query}`)) +} + +function callCreate(body: unknown) { + return POST( + new NextRequest('http://localhost:3000/api/v2/mcp-servers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + +/** What the route forwards for a bare `?workspaceId=` list. */ +const DEFAULT_LIST_ARGS = { + search: undefined, + sortBy: 'createdAt', + sortOrder: 'desc', +} + +const VALID_BODY = { + workspaceId: 'workspace-1', + name: 'Docs server', + url: 'https://mcp.example.com/sse', +} + +describe('GET /api/v2/mcp-servers', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockListWorkspaceMcpServers.mockResolvedValue([buildRow()]) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callList('workspaceId=workspace-1') + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockListWorkspaceMcpServers).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callList('') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockListWorkspaceMcpServers).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(403) + expect((await res.json()).error).toMatchObject({ code: 'FORBIDDEN', message: 'Access denied' }) + expect(mockListWorkspaceMcpServers).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue({ + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, + }) + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('returns the public server shape in the cursor envelope', async () => { + const res = await callList('workspaceId=workspace-1') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + id: 'mcp-abc12345', + name: 'Docs server', + description: 'Internal docs', + transport: 'streamable-http', + authType: 'headers', + url: 'https://mcp.example.com/sse', + timeout: 30000, + retries: 3, + enabled: true, + connectionStatus: 'connected', + lastError: null, + toolCount: 4, + lastToolsRefresh: '2024-01-02T00:00:00.000Z', + lastConnected: '2024-01-02T00:00:00.000Z', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + hasHeaders: true, + headerNames: ['Authorization'], + hasOauthClientSecret: false, + }, + ]) + expect(mockListWorkspaceMcpServers).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + ...DEFAULT_LIST_ARGS, + }) + }) + + it('never returns configured header values', async () => { + const res = await callList('workspaceId=workspace-1') + const raw = JSON.stringify(await res.json()) + + expect(raw).not.toContain('super-secret-token') + expect(raw).not.toContain('"headers":') + }) + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=workspace-1&sortBy=name);--`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=workspace-1&sortOrder=sideways`) + + expect(res.status).toBe(400) + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=workspace-1&search=`) + + expect(res.status).toBe(400) + }) + + it('forwards search and sort into the query and still terminates pagination', async () => { + const res = await callList(`workspaceId=workspace-1&search=report&sortBy=name&sortOrder=asc`) + + expect(res.status).toBe(200) + expect((await res.json()).nextCursor).toBeNull() + }) +}) + +describe('POST /api/v2/mcp-servers', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetMcpServerIdState.mockResolvedValue(null) + mockPerformCreateMcpServer.mockResolvedValue({ + success: true, + serverId: 'mcp-abc12345', + updated: false, + }) + mockGetWorkspaceMcpServer.mockResolvedValue(buildRow()) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(404) + expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() + }) + + it('400s when the body is missing a required field', async () => { + const res = await callCreate({ workspaceId: 'workspace-1', name: 'Docs server' }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() + }) + + it('400s when the url carries an environment-variable template', async () => { + const res = await callCreate({ ...VALID_BODY, url: 'https://{{MCP_HOST}}/sse' }) + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('{{ENV_VAR}}') + expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() + }) + + it('400s when the url is not an absolute http(s) URL', async () => { + const res = await callCreate({ ...VALID_BODY, url: 'file:///etc/passwd' }) + expect(res.status).toBe(400) + expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(403) + expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue({ + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('409s on a duplicate URL without letting the lib upsert', async () => { + mockGetMcpServerIdState.mockResolvedValue({ deleted: false }) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() + }) + + it('409s when a concurrent create made the lib upsert instead of insert', async () => { + mockPerformCreateMcpServer.mockResolvedValue({ + success: true, + serverId: 'mcp-abc12345', + updated: true, + }) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('revives a soft-deleted URL instead of stranding it behind a 409', async () => { + mockGetMcpServerIdState.mockResolvedValue({ deleted: true }) + mockPerformCreateMcpServer.mockResolvedValue({ + success: true, + serverId: 'mcp-abc12345', + updated: true, + }) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(201) + expect(mockPerformCreateMcpServer).toHaveBeenCalled() + }) + + it('creates the server and returns 201 with the public shape', async () => { + const res = await callCreate({ ...VALID_BODY, headers: { Authorization: 'Bearer tok' } }) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data.mcpServer).toMatchObject({ + id: 'mcp-abc12345', + name: 'Docs server', + hasHeaders: true, + headerNames: ['Authorization'], + }) + expect(body.data.mcpServer.headers).toBeUndefined() + expect(mockPerformCreateMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Docs server', + url: 'https://mcp.example.com/sse', + headers: { Authorization: 'Bearer tok' }, + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/mcp-servers/route.ts b/apps/sim/app/api/v2/mcp-servers/route.ts new file mode 100644 index 00000000000..e9a32f7251f --- /dev/null +++ b/apps/sim/app/api/v2/mcp-servers/route.ts @@ -0,0 +1,167 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateMcpServerContract, + v2ListMcpServersContract, +} from '@/lib/api/contracts/v2/mcp-servers' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performCreateMcpServer } from '@/lib/mcp/orchestration' +import { + getMcpServerIdState, + getWorkspaceMcpServer, + listWorkspaceMcpServers, +} from '@/lib/mcp/queries' +import { generateMcpServerId } from '@/lib/mcp/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toV2McpServer, v2McpOrchestrationError } from '@/app/api/v2/mcp-servers/utils' + +const logger = createLogger('V2McpServersAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/mcp-servers — List MCP servers in a workspace. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'mcp-servers') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListMcpServersContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, search, sortBy, sortOrder } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const rows = await listWorkspaceMcpServers({ workspaceId, search, sortBy, sortOrder }) + + // The per-workspace server set is small and bounded → a single full page. + return v2CursorList(rows.map(toV2McpServer), null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing MCP servers`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/mcp-servers — Register a new MCP server. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'mcp-servers') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateMcpServerContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, ...body } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + /** + * The server id is a deterministic hash of workspace + normalized URL, and + * `performCreateMcpServer` upserts onto it — a second registration of the + * same URL silently overwrites the first. The internal surface and the + * copilot rely on that; a public create must not, so the collision is + * detected here, before the lib is given a chance to clobber the row. + * + * Only a *live* row is a conflict. A soft-deleted one is revived by the lib + * rather than inserted alongside, and reporting it as a duplicate would + * strand that URL for good: the detail routes resolve live rows only, so it + * could be neither fetched, patched, nor re-created. + */ + const serverId = generateMcpServerId(workspaceId, body.url) + const idState = await getMcpServerIdState({ workspaceId, serverId }) + if (idState && !idState.deleted) { + return v2Error( + 'CONFLICT', + 'An MCP server with this URL already exists in this workspace. Update it with PATCH /api/v2/mcp-servers/{id}.' + ) + } + const revivingSoftDeleted = idState?.deleted === true + + const result = await performCreateMcpServer({ + workspaceId, + userId, + name: body.name, + description: body.description, + transport: body.transport, + url: body.url, + headers: body.headers, + timeout: body.timeout, + retries: body.retries, + enabled: body.enabled, + authType: body.authType, + oauthClientId: body.oauthClientId ?? null, + oauthClientIdProvided: body.oauthClientId !== undefined, + oauthClientSecret: body.oauthClientSecret, + oauthClientSecretProvided: body.oauthClientSecret !== undefined, + request, + }) + + if (!result.success || !result.serverId) { + return v2McpOrchestrationError(result.errorCode, result.error ?? 'Failed to register server') + } + + /** + * `updated` means the lib wrote onto an existing row. Reviving the + * soft-deleted row we already saw is the intended outcome; otherwise a + * concurrent create won the id race between the check above and the write. + */ + if (result.updated && !revivingSoftDeleted) { + return v2Error('CONFLICT', 'An MCP server with this URL already exists in this workspace.') + } + + const created = await getWorkspaceMcpServer({ workspaceId, serverId: result.serverId }) + if (!created) return v2Error('INTERNAL_ERROR', 'Internal server error') + + return v2Data({ mcpServer: toV2McpServer(created) }, { rateLimit, status: 201 }) + } catch (error) { + logger.error(`[${requestId}] Error creating MCP server`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/mcp-servers/utils.ts b/apps/sim/app/api/v2/mcp-servers/utils.ts new file mode 100644 index 00000000000..ba4fec6ee87 --- /dev/null +++ b/apps/sim/app/api/v2/mcp-servers/utils.ts @@ -0,0 +1,50 @@ +import type { NextResponse } from 'next/server' +import { type V2McpServer, v2McpServerSchema } from '@/lib/api/contracts/v2/mcp-servers' +import type { McpServerRow } from '@/lib/mcp/queries' +import { v2Error } from '@/app/api/v2/lib/response' + +/** + * Shared serialization + error mapping for the v2 MCP server surface. + */ + +/** + * Projects a stored MCP server row onto the public shape. + * + * The row is parsed through {@link v2McpServerSchema}, whose strip behaviour is + * the security boundary: `headers`, `oauthClientSecret`, `statusConfig`, and the + * rest of the row are dropped rather than enumerated by hand, so a column added + * later cannot leak by omission. Header *names* are lifted out explicitly. + */ +export function toV2McpServer(row: McpServerRow): V2McpServer { + const headers = (row.headers ?? {}) as Record + const headerNames = Object.keys(headers) + return v2McpServerSchema.parse({ + ...row, + hasHeaders: headerNames.length > 0, + headerNames, + hasOauthClientSecret: Boolean(row.oauthClientSecret), + }) +} + +/** + * Renders an MCP orchestration failure in the v2 error envelope. + * + * `forbidden` is the domain-allowlist / SSRF rejection and keeps its 403. + * `bad_gateway` is a DNS failure on the caller-supplied hostname — the caller's + * input is at fault, so it surfaces as a 400 rather than implying a Sim outage. + */ +export function v2McpOrchestrationError( + errorCode: string | undefined, + message: string +): NextResponse { + switch (errorCode) { + case 'not_found': + return v2Error('NOT_FOUND', 'MCP server not found') + case 'forbidden': + return v2Error('FORBIDDEN', message) + case 'bad_gateway': + return v2Error('BAD_REQUEST', message) + default: + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +} diff --git a/apps/sim/app/api/v2/skills/[id]/route.test.ts b/apps/sim/app/api/v2/skills/[id]/route.test.ts new file mode 100644 index 00000000000..834191497ff --- /dev/null +++ b/apps/sim/app/api/v2/skills/[id]/route.test.ts @@ -0,0 +1,331 @@ +/** + * @vitest-environment node + * + * Public v2 skill detail: the get-by-id that has no internal equivalent, plus + * the per-id update/delete that replaced the bulk upsert. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetSkillById, + mockPerformUpdateSkill, + mockPerformDeleteSkill, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetSkillById: vi.fn(), + mockPerformUpdateSkill: vi.fn(), + mockPerformDeleteSkill: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/workflows/skills/operations', () => ({ + getSkillById: mockGetSkillById, +})) + +vi.mock('@/lib/skills/orchestration', () => ({ + performUpdateSkill: mockPerformUpdateSkill, + performDeleteSkill: mockPerformDeleteSkill, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { DELETE, GET, PATCH } from '@/app/api/v2/skills/[id]/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +function buildSkill(overrides: Record = {}) { + return { + id: 'skl_abc123', + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'refund-policy', + description: 'How to handle refunds', + content: '# Refund policy', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +const routeContext = () => ({ params: Promise.resolve({ id: 'skl_abc123' }) }) +const url = (query = 'workspaceId=workspace-1') => + `http://localhost:3000/api/v2/skills/skl_abc123?${query}` + +const callGet = (query?: string) => GET(new NextRequest(url(query)), routeContext()) +const callDelete = (query?: string) => + DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext()) + +function callPatch(body: unknown) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/skills/skl_abc123', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + routeContext() + ) +} + +describe('GET /api/v2/skills/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetSkillById.mockResolvedValue(buildSkill()) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockGetSkillById).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callGet('') + expect(res.status).toBe(400) + expect(mockGetSkillById).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callGet() + expect(res.status).toBe(403) + expect(mockGetSkillById).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callGet() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the skill is not in the workspace', async () => { + mockGetSkillById.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('returns the single skill including its body', async () => { + const res = await callGet() + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ + skill: { + id: 'skl_abc123', + name: 'refund-policy', + description: 'How to handle refunds', + content: '# Refund policy', + readOnly: false, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + }) + expect(mockGetSkillById).toHaveBeenCalledWith({ + skillId: 'skl_abc123', + workspaceId: 'workspace-1', + }) + }) +}) + +describe('PATCH /api/v2/skills/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformUpdateSkill.mockResolvedValue({ + success: true, + skill: buildSkill({ description: 'Updated' }), + }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + + expect(res.status).toBe(404) + expect(mockPerformUpdateSkill).not.toHaveBeenCalled() + }) + + it('400s when no field to change is supplied', async () => { + const res = await callPatch({ workspaceId: 'workspace-1' }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformUpdateSkill).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + expect(res.status).toBe(403) + expect(mockPerformUpdateSkill).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('403s when the caller is not a skill editor', async () => { + mockPerformUpdateSkill.mockResolvedValue({ + success: false, + error: 'Skill editor access required to modify "refund-policy"', + errorCode: 'forbidden', + }) + const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + expect(res.status).toBe(403) + expect((await res.json()).error.code).toBe('FORBIDDEN') + }) + + it('400s when the orchestration rejects a built-in skill', async () => { + mockPerformUpdateSkill.mockResolvedValue({ + success: false, + error: 'Built-in skills are read-only and cannot be modified', + errorCode: 'validation', + }) + const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('Built-in') + }) + + it('gates on workspace read, leaving edit rights to the per-skill editor check', async () => { + await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + 'workspace-1', + 'read' + ) + }) + + it('updates the skill and returns the single skill', async () => { + const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.skill.description).toBe('Updated') + expect(Array.isArray(body.data)).toBe(false) + expect(mockPerformUpdateSkill).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + skillId: 'skl_abc123', + description: 'Updated', + source: 'api', + }) + ) + }) +}) + +describe('DELETE /api/v2/skills/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformDeleteSkill.mockResolvedValue({ success: true, skill: buildSkill() }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callDelete() + + expect(res.status).toBe(404) + expect(mockPerformDeleteSkill).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callDelete('') + expect(res.status).toBe(400) + expect(mockPerformDeleteSkill).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callDelete() + expect(res.status).toBe(403) + expect(mockPerformDeleteSkill).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callDelete() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('400s when the skill is a read-only built-in', async () => { + mockPerformDeleteSkill.mockResolvedValue({ + success: false, + error: 'Built-in skills are read-only and cannot be modified', + errorCode: 'validation', + }) + const res = await callDelete() + expect(res.status).toBe(400) + }) + + it('gates on workspace read, leaving delete rights to the per-skill editor check', async () => { + await callDelete() + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + 'workspace-1', + 'read' + ) + }) + + it('deletes the skill and acknowledges the id', async () => { + const res = await callDelete() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: { id: 'skl_abc123', deleted: true } }) + expect(mockPerformDeleteSkill).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + skillId: 'skl_abc123', + source: 'api', + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/skills/[id]/route.ts b/apps/sim/app/api/v2/skills/[id]/route.ts new file mode 100644 index 00000000000..2cb7d1f2017 --- /dev/null +++ b/apps/sim/app/api/v2/skills/[id]/route.ts @@ -0,0 +1,169 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteSkillContract, + v2GetSkillContract, + v2UpdateSkillContract, +} from '@/lib/api/contracts/v2/skills' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performDeleteSkill, performUpdateSkill } from '@/lib/skills/orchestration' +import { getSkillById } from '@/lib/workflows/skills/operations' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toV2Skill, v2SkillOrchestrationError } from '@/app/api/v2/skills/utils' + +const logger = createLogger('V2SkillDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RouteContext { + params: Promise<{ id: string }> +} + +/** GET /api/v2/skills/[id] — Fetch a single skill, including its body. */ +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'skill-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetSkillContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const skill = await getSkillById({ skillId: id, workspaceId }) + if (!skill) return v2Error('NOT_FOUND', 'Skill not found') + + return v2Data({ skill: toV2Skill(skill) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error fetching skill`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/skills/[id] — Update a skill. Omitted fields keep their values. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'skill-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateSkillContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, name, description, content } = parsed.data.body + + /** + * Editing an existing skill is gated per skill, not per workspace: an + * explicit editor grant (or workspace admin) is the authority, and + * `performUpdateSkill` enforces it. Requiring workspace `write` here would + * reject a legitimate skill editor who only holds `read` — stricter than the + * UI and than what this endpoint documents. Creating still needs `write`. + */ + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const result = await performUpdateSkill({ + workspaceId, + userId, + skillId: id, + name, + description, + content, + source: 'api', + request, + }) + + if (!result.success || !result.skill) { + return v2SkillOrchestrationError(result.errorCode, result.error ?? 'Failed to update skill') + } + + return v2Data({ skill: toV2Skill(result.skill) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error updating skill`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/skills/[id] — Delete a skill. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'skill-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteSkillContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + // Gated per skill by `performDeleteSkill`, same as PATCH above. + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const result = await performDeleteSkill({ + workspaceId, + userId, + skillId: id, + source: 'api', + request, + }) + + if (!result.success) { + return v2SkillOrchestrationError(result.errorCode, result.error ?? 'Failed to delete skill') + } + + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting skill`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/skills/route.test.ts b/apps/sim/app/api/v2/skills/route.test.ts new file mode 100644 index 00000000000..8e1c5131c2e --- /dev/null +++ b/apps/sim/app/api/v2/skills/route.test.ts @@ -0,0 +1,290 @@ +/** + * @vitest-environment node + * + * Public v2 skills list/create: gate ordering, contract validation, and the + * single-resource create that replaced the internal bulk upsert. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockListSkills, mockPerformCreateSkill } = + vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockListSkills: vi.fn(), + mockPerformCreateSkill: vi.fn(), + })) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/workflows/skills/operations', () => ({ + listSkills: mockListSkills, +})) + +vi.mock('@/lib/skills/orchestration', () => ({ + performCreateSkill: mockPerformCreateSkill, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET, POST } from '@/app/api/v2/skills/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +function buildSkill(overrides: Record = {}) { + return { + id: 'skl_abc123', + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'refund-policy', + description: 'How to handle refunds', + content: '# Refund policy\n\nAlways be kind.', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +function callList(query: string) { + return GET(new NextRequest(`http://localhost:3000/api/v2/skills?${query}`)) +} + +function callCreate(body: unknown) { + return POST( + new NextRequest('http://localhost:3000/api/v2/skills', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + +const VALID_BODY = { + workspaceId: 'workspace-1', + name: 'refund-policy', + description: 'How to handle refunds', + content: '# Refund policy', +} + +describe('GET /api/v2/skills', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockListSkills.mockResolvedValue([buildSkill()]) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callList('workspaceId=workspace-1') + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockListSkills).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callList('') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockListSkills).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(403) + expect(mockListSkills).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('returns summaries without skill bodies in the cursor envelope', async () => { + const res = await callList('workspaceId=workspace-1') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + id: 'skl_abc123', + name: 'refund-policy', + description: 'How to handle refunds', + readOnly: false, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + ]) + expect(mockListSkills).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + search: undefined, + sort: { sortBy: 'createdAt', sortOrder: 'desc' }, + }) + }) + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=workspace-1&sortBy=name);--`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=workspace-1&sortOrder=sideways`) + + expect(res.status).toBe(400) + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=workspace-1&search=`) + + expect(res.status).toBe(400) + }) + + it('forwards search and sort into the query and still terminates pagination', async () => { + const res = await callList(`workspaceId=workspace-1&search=report&sortBy=name&sortOrder=asc`) + + expect(res.status).toBe(200) + expect((await res.json()).nextCursor).toBeNull() + }) +}) + +describe('POST /api/v2/skills', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformCreateSkill.mockResolvedValue({ success: true, skill: buildSkill() }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(404) + expect(mockPerformCreateSkill).not.toHaveBeenCalled() + }) + + it('400s when the body is missing content', async () => { + const res = await callCreate({ + workspaceId: 'workspace-1', + name: 'refund-policy', + description: 'How to handle refunds', + }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformCreateSkill).not.toHaveBeenCalled() + }) + + it('400s when the name is not kebab-case', async () => { + const res = await callCreate({ ...VALID_BODY, name: 'Refund Policy' }) + expect(res.status).toBe(400) + expect(mockPerformCreateSkill).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(403) + expect(mockPerformCreateSkill).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('400s when the orchestration rejects a built-in skill name', async () => { + mockPerformCreateSkill.mockResolvedValue({ + success: false, + error: 'The skill name "deploy-workflow" is reserved by a built-in skill', + errorCode: 'validation', + }) + + const res = await callCreate({ ...VALID_BODY, name: 'deploy-workflow' }) + const body = await res.json() + + expect(res.status).toBe(400) + expect(body.error.code).toBe('BAD_REQUEST') + expect(body.error.message).toContain('built-in') + }) + + it('409s when the skill name is already taken', async () => { + mockPerformCreateSkill.mockResolvedValue({ + success: false, + error: 'The skill name "refund-policy" is unavailable in this workspace', + errorCode: 'conflict', + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('creates the skill and returns 201 with the single skill, not the workspace list', async () => { + const res = await callCreate(VALID_BODY) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data).toEqual({ + skill: { + id: 'skl_abc123', + name: 'refund-policy', + description: 'How to handle refunds', + content: '# Refund policy\n\nAlways be kind.', + readOnly: false, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + }) + expect(mockPerformCreateSkill).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'refund-policy', + description: 'How to handle refunds', + content: '# Refund policy', + source: 'api', + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/skills/route.ts b/apps/sim/app/api/v2/skills/route.ts new file mode 100644 index 00000000000..1541ca2be7f --- /dev/null +++ b/apps/sim/app/api/v2/skills/route.ts @@ -0,0 +1,116 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateSkillContract, v2ListSkillsContract } from '@/lib/api/contracts/v2/skills' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performCreateSkill } from '@/lib/skills/orchestration' +import { listSkills } from '@/lib/workflows/skills/operations' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toV2Skill, toV2SkillSummary, v2SkillOrchestrationError } from '@/app/api/v2/skills/utils' + +const logger = createLogger('V2SkillsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/skills — List skills in a workspace, built-ins included. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'skills') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListSkillsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, search, sortBy, sortOrder } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const skills = await listSkills({ workspaceId, search, sort: { sortBy, sortOrder } }) + + // The per-workspace skill set is small and bounded → a single full page. + return v2CursorList(skills.map(toV2SkillSummary), null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing skills`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/skills — Create a skill. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'skills') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateSkillContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, name, description, content } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performCreateSkill({ + workspaceId, + userId, + name, + description, + content, + source: 'api', + request, + }) + + if (!result.success || !result.skill) { + return v2SkillOrchestrationError(result.errorCode, result.error ?? 'Failed to create skill') + } + + return v2Data({ skill: toV2Skill(result.skill) }, { rateLimit, status: 201 }) + } catch (error) { + logger.error(`[${requestId}] Error creating skill`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/skills/utils.ts b/apps/sim/app/api/v2/skills/utils.ts new file mode 100644 index 00000000000..a1cc30ceb02 --- /dev/null +++ b/apps/sim/app/api/v2/skills/utils.ts @@ -0,0 +1,48 @@ +import type { skill } from '@sim/db/schema' +import type { NextResponse } from 'next/server' +import type { V2Skill, V2SkillSummary } from '@/lib/api/contracts/v2/skills' +import type { SkillOrchestrationErrorCode } from '@/lib/skills/orchestration' +import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' +import { v2Error } from '@/app/api/v2/lib/response' + +/** + * Shared serialization + error mapping for the v2 skills surface. + */ + +type SkillRow = typeof skill.$inferSelect + +/** List projection — no `content`; skill bodies are fetched per skill. */ +export function toV2SkillSummary(row: SkillRow): V2SkillSummary { + return { + id: row.id, + name: row.name, + description: row.description, + readOnly: isBuiltinSkillId(row.id), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +/** Detail projection — the summary plus the skill body. */ +export function toV2Skill(row: SkillRow): V2Skill { + return { ...toV2SkillSummary(row), content: row.content } +} + +/** Renders a skill orchestration failure in the v2 error envelope. */ +export function v2SkillOrchestrationError( + errorCode: SkillOrchestrationErrorCode | undefined, + message: string +): NextResponse { + switch (errorCode) { + case 'validation': + return v2Error('BAD_REQUEST', message) + case 'forbidden': + return v2Error('FORBIDDEN', message) + case 'not_found': + return v2Error('NOT_FOUND', 'Skill not found') + case 'conflict': + return v2Error('CONFLICT', message) + default: + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +} diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts new file mode 100644 index 00000000000..a7d6235dca0 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts @@ -0,0 +1,105 @@ +/** + * @vitest-environment node + * + * v2 column update wiring: the route authenticates, scopes, delegates to the + * orchestration function, and maps its failure classes onto the v2 envelope. + * The guards themselves are covered in lib/table/orchestration/columns.test.ts. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceScope, mockCheckAccess, mockPerformUpdate } = + vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockPerformUpdate: vi.fn(), + })) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, +})) + +vi.mock('@/lib/table', () => ({ addTableColumn: vi.fn(), deleteColumn: vi.fn() })) + +vi.mock('@/lib/table/orchestration', () => ({ + performUpdateTableColumn: mockPerformUpdate, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { PATCH } from '@/app/api/v2/tables/[tableId]/columns/route' + +const COLUMN = { id: 'col-1', name: 'Status', type: 'text' } +const TABLE = { id: 'table-1', name: 'Tasks', workspaceId: 'ws-1', schema: { columns: [COLUMN] } } + +function patch(updates: Record = { name: 'State' }) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/columns', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: 'ws-1', columnName: 'Status', updates }), + }) + return PATCH(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +describe('PATCH /api/v2/tables/[tableId]/columns', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue({ + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), + }) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockPerformUpdate.mockResolvedValue({ success: true, table: TABLE }) + }) + + it('delegates to the orchestration function with the resolved table and actor', async () => { + const res = await patch() + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ columns: [COLUMN] }) + expect(mockPerformUpdate).toHaveBeenCalledWith( + expect.objectContaining({ table: TABLE, columnName: 'Status', userId: 'user-1' }) + ) + }) + + it.each([ + ['validation', 400, 'BAD_REQUEST'], + ['not_found', 404, 'NOT_FOUND'], + ['locked', 423, 'LOCKED'], + ])('maps a %s failure to %i', async (errorCode, status, code) => { + mockPerformUpdate.mockResolvedValue({ success: false, errorCode, error: 'nope' }) + + const res = await patch() + + expect(res.status).toBe(status) + expect((await res.json()).error.code).toBe(code) + }) + + it('does not leak an internal failure message', async () => { + mockPerformUpdate.mockResolvedValue({ + success: false, + errorCode: 'internal', + error: 'connection string leaked', + }) + + const res = await patch() + + expect(res.status).toBe(500) + expect(await res.text()).not.toContain('connection string') + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts new file mode 100644 index 00000000000..ce480142cab --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts @@ -0,0 +1,215 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2AddTableColumnContract, + v2DeleteTableColumnContract, + v2UpdateTableColumnContract, +} from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { addTableColumn, deleteColumn } from '@/lib/table' +import { performUpdateTableColumn } from '@/lib/table/orchestration' +import { checkAccess, normalizeColumn } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2ErrorForOrchestration, + 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 gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2AddTableColumnContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const updatedTable = await addTableColumn(tableId, validated.column, requestId) + + recordAudit({ + workspaceId: validated.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Added column "${validated.column.name}" to table "${table.name}"`, + metadata: { column: validated.column }, + request, + }) + + return v2Data({ columns: updatedTable.schema.columns.map(normalizeColumn) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + logger.error(`[${requestId}] Error adding column to table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/tables/[tableId]/columns — Update a column (rename, type change, constraints). */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: ColumnsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-columns') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateTableColumnContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const outcome = await performUpdateTableColumn({ + table, + columnName: validated.columnName, + userId, + updates: validated.updates, + requestId, + request, + }) + if (!outcome.success || !outcome.table) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? 'Failed to update column') + } + + return v2Data({ columns: outcome.table.schema.columns.map(normalizeColumn) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + logger.error(`[${requestId}] Error updating column in table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/tables/[tableId]/columns — Delete a column from the table schema. */ +export const DELETE = withRouteHandler( + async (request: NextRequest, context: ColumnsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-columns') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteTableColumnContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const updatedTable = await deleteColumn( + { tableId, columnName: validated.columnName }, + requestId + ) + + recordAudit({ + workspaceId: validated.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Deleted column "${validated.columnName}" from table "${table.name}"`, + metadata: { columnName: validated.columnName }, + request, + }) + + return v2Data({ columns: updatedTable.schema.columns.map(normalizeColumn) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + logger.error(`[${requestId}] Error deleting column from table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts index 37d8e774d8b..902b8b3bac3 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts @@ -2,44 +2,40 @@ * @vitest-environment node * * Public v2 query POST: typed predicate name→id translation, bounded-default vs - * explicit-unbounded limit, cursor validation, workspace scoping, - * and name-keyed row output. + * explicit-unbounded limit, cursor validation, workspace scoping, and + * name-keyed row output in the `{ data, nextCursor }` envelope. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TableDefinition } from '@/lib/table/types' -const { mockCheckAccess, mockQueryRows, mockCheckRateLimit, mockCheckWorkspaceScope, mockGate } = - vi.hoisted(() => ({ - mockCheckAccess: vi.fn(), - mockQueryRows: vi.fn(), - mockCheckRateLimit: vi.fn(), - mockCheckWorkspaceScope: vi.fn(), - mockGate: vi.fn(), - })) - -vi.mock('@/app/api/v1/middleware', async () => { - const { NextResponse } = await import('next/server') - return { - checkRateLimit: mockCheckRateLimit, - checkWorkspaceScope: mockCheckWorkspaceScope, - createRateLimitResponse: (r: { error?: string }) => - NextResponse.json( - { error: r.error ?? 'Rate limit exceeded' }, - { status: r.error ? 401 : 429 } - ), - } -}) +const { + mockCheckAccess, + mockQueryRows, + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockIsFeatureEnabled, + mockGetWorkspaceOrganizationId, +} = vi.hoisted(() => ({ + mockCheckAccess: vi.fn(), + mockQueryRows: vi.fn(), + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockIsFeatureEnabled: vi.fn(), + mockGetWorkspaceOrganizationId: vi.fn(), +})) -vi.mock('@/app/api/table/utils', async () => { - const { NextResponse } = await import('next/server') - return { - checkAccess: mockCheckAccess, - accessError: (result: { status: number }) => - NextResponse.json({ error: 'Access denied' }, { status: result.status }), - tablesV2GateError: mockGate, - } -}) +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) vi.mock('@/lib/table', async () => { const columnKeys = await import('@/lib/table/column-keys') @@ -48,9 +44,32 @@ vi.mock('@/lib/table', async () => { vi.mock('@/lib/table/rows/service', () => ({ queryRows: mockQueryRows })) +vi.mock('@/lib/core/config/feature-flags', () => ({ + isFeatureEnabled: mockIsFeatureEnabled, +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceOrganizationId: mockGetWorkspaceOrganizationId, +})) + import { encodeCursor } from '@/lib/table/rows/cursor' + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + import { POST } from '@/app/api/v2/tables/[tableId]/query/route' +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'workspace-1', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + function buildTable(): TableDefinition { return { id: 'tbl_1', @@ -95,50 +114,24 @@ function callQuery(body: Record) { describe('POST /api/v2/tables/[tableId]/query', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue({ - allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'workspace-1', - }) - mockCheckWorkspaceScope.mockResolvedValue(null) + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) mockQueryRows.mockResolvedValue(EMPTY_RESULT) - mockGate.mockResolvedValue(null) + mockIsFeatureEnabled.mockResolvedValue(true) + mockGetWorkspaceOrganizationId.mockResolvedValue('org-1') }) - it('returns 404 when the tables-v2-api flag is off', async () => { - const { NextResponse } = await import('next/server') - mockGate.mockResolvedValue(NextResponse.json({ error: 'Not found' }, { status: 404 })) - const res = await callQuery({ workspaceId: 'workspace-1' }) - expect(res.status).toBe(404) - expect(mockQueryRows).not.toHaveBeenCalled() - }) + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - it('runs the flag gate only after the access check, so it cannot leak a cohort oracle', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) const res = await callQuery({ workspaceId: 'workspace-1' }) - expect(res.status).toBe(403) - expect(mockGate).not.toHaveBeenCalled() - }) - it('translates a name-keyed predicate to storage ids', async () => { - const res = await callQuery({ - workspaceId: 'workspace-1', - predicate: { - all: [ - { field: 'status', op: 'eq', value: 'active' }, - { field: 'wins', op: 'gte', value: 10 }, - ], - }, - }) - expect(res.status).toBe(200) - expect(mockQueryRows.mock.calls[0][1].predicate).toEqual({ - all: [ - { field: 'col_status', op: 'eq', value: 'active' }, - { field: 'col_wins', op: 'gte', value: 10 }, - ], - }) + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockQueryRows).not.toHaveBeenCalled() }) it('applies the bounded default limit when omitted', async () => { @@ -174,7 +167,7 @@ describe('POST /api/v2/tables/[tableId]/query', () => { predicate: { all: [{ field: 'nope', op: 'eq', value: 1 }] }, }) expect(res.status).toBe(400) - expect((await res.json()).error).toMatch(/Unknown filter column/) + expect((await res.json()).error.message).toMatch(/Unknown filter column/) }) it('rejects a keyset cursor combined with a custom sort', async () => { @@ -189,7 +182,7 @@ describe('POST /api/v2/tables/[tableId]/query', () => { cursor, }) expect(res.status).toBe(400) - expect((await res.json()).code).toBe('CURSOR_SORT_CONFLICT') + expect((await res.json()).error.details.code).toBe('CURSOR_SORT_CONFLICT') }) it('returns 400 INVALID_CURSOR for a malformed cursor', async () => { @@ -198,23 +191,28 @@ describe('POST /api/v2/tables/[tableId]/query', () => { cursor: Buffer.from('42').toString('base64url'), }) expect(res.status).toBe(400) - expect((await res.json()).code).toBe('INVALID_CURSOR') + expect((await res.json()).error.details.code).toBe('INVALID_CURSOR') }) - it('surfaces a workspace-scope 403 from the middleware', async () => { - const { NextResponse } = await import('next/server') - mockCheckWorkspaceScope.mockResolvedValue( - NextResponse.json({ error: 'not authorized' }, { status: 403 }) - ) + it('surfaces a workspace-scope 403 in the v2 error envelope', async () => { + mockResolveWorkspaceScope.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'API key is not authorized for this workspace', + }) const res = await callQuery({ workspaceId: 'workspace-1' }) expect(res.status).toBe(403) + expect((await res.json()).error.code).toBe('FORBIDDEN') expect(mockQueryRows).not.toHaveBeenCalled() }) - it('rejects a workspace-id mismatch against the table', async () => { + it('masks a workspace-id mismatch against the table as 404', async () => { const res = await callQuery({ workspaceId: 'other-ws' }) - expect(res.status).toBe(400) - expect((await res.json()).error).toBe('Invalid workspace ID') + expect(res.status).toBe(404) + expect((await res.json()).error).toMatchObject({ + code: 'NOT_FOUND', + message: 'Table not found', + }) }) it('returns name-keyed row data with no storage internals and a private cache header', async () => { @@ -238,7 +236,9 @@ describe('POST /api/v2/tables/[tableId]/query', () => { }) const res = await callQuery({ workspaceId: 'workspace-1' }) expect(res.headers.get('Cache-Control')).toBe('private, no-store') - expect((await res.json()).data.rows[0]).toEqual({ + const body = await res.json() + expect(body.nextCursor).toBeNull() + expect(body.data[0]).toEqual({ id: 'r1', data: { status: 'active', wins: 12 }, createdAt: '2024-02-02T00:00:00.000Z', @@ -247,7 +247,13 @@ describe('POST /api/v2/tables/[tableId]/query', () => { }) it('returns the rate-limit response when the limiter denies the request', async () => { - mockCheckRateLimit.mockResolvedValue({ allowed: false }) + mockCheckRateLimit.mockResolvedValue({ + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, + }) const res = await callQuery({ workspaceId: 'workspace-1' }) expect(res.status).toBe(429) expect(mockCheckAccess).not.toHaveBeenCalled() diff --git a/apps/sim/app/api/v2/tables/[tableId]/query/route.ts b/apps/sim/app/api/v2/tables/[tableId]/query/route.ts index 4337c0b402c..495c8aeff48 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/query/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/query/route.ts @@ -1,8 +1,9 @@ import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' import { TABLE_QUERY_MAX_BODY_BYTES } from '@/lib/api/contracts/tables' import { V2_DEFAULT_ROW_LIMIT, v2QueryRowsContract } from '@/lib/api/contracts/v2/tables' -import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server' +import { isZodError, parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { Sort, TablePredicate, TableSchema } from '@/lib/table' @@ -13,21 +14,23 @@ import { validatePredicate, validateSortSpec } from '@/lib/table/query-builder/v import { assertCursorSortBinding, decodeCursor } from '@/lib/table/rows/cursor' import { queryRows } from '@/lib/table/rows/service' import { predicateToStorage } from '@/lib/table/select-values' -import { accessError, checkAccess, tablesV2GateError } from '@/app/api/table/utils' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { - checkRateLimit, - checkWorkspaceScope, - createRateLimitResponse, -} from '@/app/api/v1/middleware' + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiRow } from '@/app/api/v2/tables/utils' const logger = createLogger('V2TableQueryAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -/** Filters may carry user data; keep query responses out of shared caches. */ -const PRIVATE_NO_STORE = { 'Cache-Control': 'private, no-store' } as const - interface QueryRouteParams { params: Promise<{ tableId: string }> } @@ -41,34 +44,35 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Query const requestId = generateRequestId() try { - const rateLimit = await checkRateLimit(request, 'v2-table-rows') - if (!rateLimit.allowed) return createRateLimitResponse(rateLimit) + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2QueryRowsContract, request, context, { maxBodyBytes: TABLE_QUERY_MAX_BODY_BYTES, + validationErrorResponse: v2ValidationError, }) if (!parsed.success) return parsed.response const { tableId } = parsed.data.params const { workspaceId, sort, cursor: cursorToken, limit } = parsed.data.body - const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return scopeError + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) const accessResult = await checkAccess(tableId, userId, 'read') - if (!accessResult.ok) return accessError(accessResult, requestId, tableId) - const { table } = accessResult + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!accessResult.ok) return v2Error('NOT_FOUND', 'Table not found') + const { table } = accessResult if (workspaceId !== table.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + return v2Error('NOT_FOUND', 'Table not found') } - // After authz: the gate reads the workspace's org off the primary DB, and its - // 404 would otherwise distinguish "not in the rollout cohort" from "no access". - const gateError = await tablesV2GateError(userId, workspaceId) - if (gateError) return gateError - const schema = table.schema as TableSchema const cursor = cursorToken ? decodeCursor(cursorToken) : undefined @@ -108,44 +112,29 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Query limit: effectiveLimit, after: cursor?.after, offset: cursor?.offset, - includeTotal: !cursorToken, + includeTotal: false, withExecutions: false, }, requestId ) - return NextResponse.json( - { - success: true, - data: { - rows: result.rows.map((r) => ({ - id: r.id, - data: toNamedRow(r.data), - createdAt: - r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt), - updatedAt: - r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt), - })), - rowCount: result.rowCount, - totalCount: result.totalCount, - limit: result.limit, - nextCursor: result.nextCursor, - }, - }, - { headers: PRIVATE_NO_STORE } + return v2CursorList( + result.rows.map((r) => toApiRow(r, toNamedRow)), + result.nextCursor, + { rateLimit } ) } catch (error) { - const validationResponse = validationErrorResponseFromError(error) - if (validationResponse) return validationResponse + if (isZodError(error)) return v2ValidationError(error) if (error instanceof TableQueryValidationError) { - return NextResponse.json( - { error: error.message, ...(error.code ? { code: error.code } : {}) }, - { status: 400, headers: PRIVATE_NO_STORE } - ) + return v2Error('BAD_REQUEST', error.message, { + details: error.code ? { code: error.code } : undefined, + }) } - logger.error(`[${requestId}] Error querying rows (v2 public):`, error) - return NextResponse.json({ error: 'Failed to query rows' }, { status: 500 }) + logger.error(`[${requestId}] Error querying rows (v2 public)`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') } }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts new file mode 100644 index 00000000000..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 new file mode 100644 index 00000000000..55e2d792f8f --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -0,0 +1,117 @@ +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 { 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, v2TableLockError } 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 gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetTableContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!result.ok) return v2Error('NOT_FOUND', 'Table not found') + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + 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 gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteTableContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const outcome = await performDeleteTable({ table: result.table, userId, requestId, request }) + if (!outcome.success) { + return 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'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts new file mode 100644 index 00000000000..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 new file mode 100644 index 00000000000..b0bb10b78d3 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts @@ -0,0 +1,227 @@ +import { db } from '@sim/db' +import { userTableRows } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { + v2DeleteTableRowContract, + v2GetTableRowContract, + v2UpdateTableRowContract, +} from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { RowData, TableSchema } from '@/lib/table' +import { buildIdByName, rowDataNameToId, updateRow } from '@/lib/table' +import { namedRowMapper } from '@/lib/table/cell-format' +import { performDeleteTableRow } from '@/lib/table/orchestration' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiRow, v2TableAccessError, v2TableLockError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableRowAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RowRouteParams { + params: Promise<{ tableId: string; rowId: string }> +} + +/** GET /api/v2/tables/[tableId]/rows/[rowId] — Get a single row. */ +export const GET = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-row-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetTableRowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, rowId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!result.ok) return v2Error('NOT_FOUND', 'Table not found') + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const [row] = await db + .select({ + id: userTableRows.id, + data: userTableRows.data, + createdAt: userTableRows.createdAt, + updatedAt: userTableRows.updatedAt, + }) + .from(userTableRows) + .where( + and( + eq(userTableRows.id, rowId), + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId) + ) + ) + .limit(1) + + if (!row) return v2Error('NOT_FOUND', 'Row not found') + + const toNamedRow = namedRowMapper((result.table.schema as TableSchema).columns) + return v2Data( + { + row: toApiRow( + { + id: row.id, + data: row.data as RowData, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }, + toNamedRow + ), + }, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error getting row`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/tables/[tableId]/rows/[rowId] — Partial update a single row. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-row-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateTableRowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, rowId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const idByName = buildIdByName(table.schema as TableSchema) + const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) + const updatedRow = await updateRow( + { + tableId, + rowId, + data: rowDataNameToId(validated.data as RowData, idByName), + workspaceId: validated.workspaceId, + actorUserId: userId, + }, + table, + requestId + ) + // No `cancellationGuard` is passed, so `updateRow` can't return null here. + // Defensive narrowing for TypeScript. + if (!updatedRow) return v2Error('NOT_FOUND', 'Row not found') + + return v2Data({ row: toApiRow(updatedRow, toNamedRow) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + logger.error(`[${requestId}] Error updating row`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/tables/[tableId]/rows/[rowId] — Delete a single row. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-row-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteTableRowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, rowId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const outcome = await performDeleteTableRow({ table: result.table, rowId, requestId }) + if (!outcome.success) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? 'Failed to delete row') + } + + // v2 mirrors the bulk delete shape: always returns `deletedRowIds`. + return v2Data({ deletedCount: 1, deletedRowIds: [rowId] }, { rateLimit }) + } catch (error) { + const lockError = v2TableLockError(error) + if (lockError) return lockError + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error(`[${requestId}] Error deleting row`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts new file mode 100644 index 00000000000..a2677af979c --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts @@ -0,0 +1,420 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest, NextResponse } from 'next/server' +import type { V1BatchInsertTableRowsBody } from '@/lib/api/contracts/v1/tables' +import { + v2CreateTableRowsContract, + v2DeleteTableRowsContract, + v2ListTableRowsContract, + v2UpdateRowsByFilterContract, +} from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { RowData, TableSchema } from '@/lib/table' +import { + batchInsertRows, + buildIdByName, + deleteRowsByFilter, + deleteRowsByIds, + insertRow, + rowDataNameToId, + updateRowsByFilter, + validateBatchRows, + validateRowData, + validateRowSize, +} from '@/lib/table' +import { namedRowMapper } from '@/lib/table/cell-format' +import { TableQueryValidationError } from '@/lib/table/errors' +import { queryRows } from '@/lib/table/rows/service' +import { checkAccess } from '@/app/api/table/utils' +import { + checkRateLimit, + type RateLimitResult, + resolveWorkspaceScope, +} from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { + toApiRow, + v2BulkPredicateToFilter, + v2RowValidationError, + v2RowWriteError, + v2TableAccessError, +} from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableRowsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRowsRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * Inserts a validated batch of rows. Authorizes against the table's own + * workspace (IDOR guard) before any write, translates name-keyed row data to + * storage ids, and returns the inserted rows in the canonical v2 envelope. + */ +async function handleBatchInsert( + requestId: string, + tableId: string, + validated: V1BatchInsertTableRowsBody, + userId: string, + rateLimit: RateLimitResult +): Promise { + const accessResult = await checkAccess(tableId, userId, 'write') + if (!accessResult.ok) return v2TableAccessError(accessResult) + + const { table } = accessResult + if (validated.workspaceId !== table.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + // External callers key row data by column name; storage keys by id. + const idByName = buildIdByName(table.schema as TableSchema) + const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) + const rows = (validated.rows as RowData[]).map((r) => rowDataNameToId(r, idByName)) + + const validation = await validateBatchRows({ + rows, + schema: table.schema as TableSchema, + tableId, + }) + if (!validation.valid) return v2RowValidationError(validation.response) + + try { + const insertedRows = await batchInsertRows( + { tableId, rows, workspaceId: validated.workspaceId, userId }, + table, + requestId + ) + + return v2Data( + { + rows: insertedRows.map((r) => toApiRow(r, toNamedRow)), + insertedCount: insertedRows.length, + }, + { rateLimit } + ) + } catch (error) { + const response = v2RowWriteError(error) + if (response) return response + + logger.error(`[${requestId}] Error batch inserting rows`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +} + +/** + * GET /api/v2/tables/[tableId]/rows — Plain cursor page over the default row + * order. Filtered/sorted reads go through `POST /query`. + */ +export const GET = withRouteHandler(async (request: NextRequest, context: TableRowsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ListTableRowsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const accessResult = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!accessResult.ok) return v2Error('NOT_FOUND', 'Table not found') + + const { table } = accessResult + if (validated.workspaceId !== table.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) + + // Cursor-uniform v2 pagination: the opaque cursor encodes the underlying + // offset (upgradeable to keyset later without an interface change). Total row + // count is intentionally omitted here — it's available as `rowCount` on the table. + const offset = validated.cursor + ? (decodeCursor<{ offset: number }>(validated.cursor)?.offset ?? 0) + : 0 + + const result = await queryRows( + table, + { + limit: validated.limit, + offset, + includeTotal: true, + withExecutions: false, + }, + requestId + ) + + const total = result.totalCount ?? 0 + const hasMore = offset + result.rowCount < total + const nextCursor = hasMore ? encodeCursor({ offset: offset + validated.limit }) : null + + return v2CursorList( + result.rows.map((r) => toApiRow(r, toNamedRow)), + nextCursor, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + logger.error(`[${requestId}] Error querying rows`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/tables/[tableId]/rows — Insert row(s). Supports single or batch. */ +export const POST = withRouteHandler( + async (request: NextRequest, context: TableRowsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2CreateTableRowsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + + if ('rows' in parsed.data.body) { + const batchValidated = parsed.data.body + const scopeError = await resolveWorkspaceScope(rateLimit, batchValidated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + return handleBatchInsert(requestId, tableId, batchValidated, userId, rateLimit) + } + + const validated = parsed.data.body + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const accessResult = await checkAccess(tableId, userId, 'write') + if (!accessResult.ok) return v2TableAccessError(accessResult) + + const { table } = accessResult + if (validated.workspaceId !== table.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const idByName = buildIdByName(table.schema as TableSchema) + const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) + const rowData = rowDataNameToId(validated.data as RowData, idByName) + + const validation = await validateRowData({ + rowData, + schema: table.schema as TableSchema, + tableId, + }) + if (!validation.valid) return v2RowValidationError(validation.response) + + const row = await insertRow( + { tableId, data: rowData, workspaceId: validated.workspaceId, userId }, + table, + requestId + ) + + return v2Data({ row: toApiRow(row, toNamedRow) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + const response = v2RowWriteError(error) + if (response) return response + + logger.error(`[${requestId}] Error inserting row`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +/** PUT /api/v2/tables/[tableId]/rows — Bulk update rows by predicate filter. */ +export const PUT = withRouteHandler(async (request: NextRequest, context: TableRowsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateRowsByFilterContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const accessResult = await checkAccess(tableId, userId, 'write') + if (!accessResult.ok) return v2TableAccessError(accessResult) + + const { table } = accessResult + if (validated.workspaceId !== table.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const idByName = buildIdByName(table.schema as TableSchema) + const patchData = rowDataNameToId(validated.data as RowData, idByName) + + const sizeValidation = validateRowSize(patchData) + if (!sizeValidation.valid) { + return v2Error('BAD_REQUEST', 'Invalid row data', { details: sizeValidation.errors }) + } + + const result = await updateRowsByFilter( + table, + { + filter: v2BulkPredicateToFilter(validated.filter, table.schema as TableSchema), + data: patchData, + limit: validated.limit, + actorUserId: userId, + }, + requestId + ) + + // v2 always returns `updatedRowIds` ([] when nothing matched); v1 dropped it + // on the zero-match branch. + return v2Data( + { updatedCount: result.affectedCount, updatedRowIds: result.affectedRowIds }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + const response = v2RowWriteError(error) + if (response) return response + + logger.error(`[${requestId}] Error updating rows by filter`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/tables/[tableId]/rows — Delete rows by predicate filter or IDs. */ +export const DELETE = withRouteHandler( + async (request: NextRequest, context: TableRowsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteTableRowsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const accessResult = await checkAccess(tableId, userId, 'write') + if (!accessResult.ok) return v2TableAccessError(accessResult) + + const { table } = accessResult + if (validated.workspaceId !== table.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + // id-based and filter-based deletes share one envelope; `requestedCount`/ + // `missingRowIds` are populated only for the id-based delete (which has a + // requested set) and omitted for the filter-based delete. + if (validated.rowIds) { + const result = await deleteRowsByIds( + table, + { tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId }, + requestId + ) + + return v2Data( + { + deletedCount: result.deletedCount, + deletedRowIds: result.deletedRowIds, + requestedCount: result.requestedCount, + missingRowIds: result.missingRowIds, + }, + { rateLimit } + ) + } + + const result = await deleteRowsByFilter( + table, + { + filter: v2BulkPredicateToFilter(validated.filter!, table.schema as TableSchema), + limit: validated.limit, + }, + requestId + ) + + return v2Data( + { deletedCount: result.affectedCount, deletedRowIds: result.affectedRowIds }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + const response = v2RowWriteError(error) + if (response) return response + + logger.error(`[${requestId}] Error deleting rows`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts new file mode 100644 index 00000000000..a8b4c21593b --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts @@ -0,0 +1,94 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2UpsertTableRowContract } from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { RowData, TableSchema } from '@/lib/table' +import { buildIdByName, rowDataNameToId, upsertRow } from '@/lib/table' +import { namedRowMapper } from '@/lib/table/cell-format' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiRow, v2TableAccessError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableUpsertAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface UpsertRouteParams { + params: Promise<{ tableId: string }> +} + +/** POST /api/v2/tables/[tableId]/rows/upsert — Insert or update a row based on unique columns. */ +export const POST = withRouteHandler(async (request: NextRequest, context: UpsertRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpsertTableRowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const idByName = buildIdByName(table.schema as TableSchema) + const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) + const upsertResult = await upsertRow( + { + tableId, + workspaceId: validated.workspaceId, + data: rowDataNameToId(validated.data as RowData, idByName), + userId, + conflictTarget: validated.conflictTarget, + }, + table, + requestId + ) + + return v2Data( + { row: toApiRow(upsertResult.row, toNamedRow), operation: upsertResult.operation }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + logger.error(`[${requestId}] Error upserting row`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/route.test.ts b/apps/sim/app/api/v2/tables/route.test.ts index 2880355c8df..f13c00e3027 100644 --- a/apps/sim/app/api/v2/tables/route.test.ts +++ b/apps/sim/app/api/v2/tables/route.test.ts @@ -1,33 +1,31 @@ /** * @vitest-environment node * - * Public v2 tables list: auth/scope gating, typed summary output, private cache header. + * Public v2 tables list: auth/scope gating, rollout gate ordering, typed + * summary output in the `{ data, nextCursor }` envelope, private cache header. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TableDefinition } from '@/lib/table/types' -const { mockListTables, mockCheckRateLimit, mockValidateWorkspaceAccess, mockGate } = vi.hoisted( - () => ({ - mockListTables: vi.fn(), - mockCheckRateLimit: vi.fn(), - mockValidateWorkspaceAccess: vi.fn(), - mockGate: vi.fn(), - }) -) +const { + mockListTables, + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockIsFeatureEnabled, + mockGetWorkspaceOrganizationId, +} = vi.hoisted(() => ({ + mockListTables: vi.fn(), + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockIsFeatureEnabled: vi.fn(), + mockGetWorkspaceOrganizationId: vi.fn(), +})) -vi.mock('@/app/api/v1/middleware', async () => { - const { NextResponse } = await import('next/server') - return { - checkRateLimit: mockCheckRateLimit, - validateWorkspaceAccess: mockValidateWorkspaceAccess, - createRateLimitResponse: (r: { error?: string }) => - NextResponse.json( - { error: r.error ?? 'Rate limit exceeded' }, - { status: r.error ? 401 : 429 } - ), - } -}) +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) vi.mock('@/lib/table', async () => { const actual = await import('@/lib/table/column-keys') @@ -36,11 +34,33 @@ vi.mock('@/lib/table', async () => { vi.mock('@/app/api/table/utils', () => ({ normalizeColumn: (col: Record) => col, - tablesV2GateError: mockGate, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/lib/core/config/feature-flags', () => ({ + isFeatureEnabled: mockIsFeatureEnabled, +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceOrganizationId: mockGetWorkspaceOrganizationId, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), })) import { GET } from '@/app/api/v2/tables/route' +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + function buildTable(): TableDefinition { return { id: 'tbl_1', @@ -66,66 +86,79 @@ function callList(query: string) { describe('GET /api/v2/tables', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1', keyType: 'workspace' }) - mockValidateWorkspaceAccess.mockResolvedValue(null) + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) mockListTables.mockResolvedValue([buildTable()]) - mockGate.mockResolvedValue(null) + mockIsFeatureEnabled.mockResolvedValue(true) + mockGetWorkspaceOrganizationId.mockResolvedValue('org-1') }) - it('returns 404 when the tables-v2-api flag is off', async () => { - const { NextResponse } = await import('next/server') - mockGate.mockResolvedValue(NextResponse.json({ error: 'Not found' }, { status: 404 })) - const res = await callList('workspaceId=workspace-1') - expect(res.status).toBe(404) - expect(mockListTables).not.toHaveBeenCalled() - }) + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - it('runs the flag gate only after the access check, so it cannot leak a cohort oracle', async () => { - const { NextResponse } = await import('next/server') - mockValidateWorkspaceAccess.mockResolvedValue( - NextResponse.json({ error: 'Access denied' }, { status: 403 }) - ) const res = await callList('workspaceId=workspace-1') - expect(res.status).toBe(403) - expect(mockGate).not.toHaveBeenCalled() - }) - it('returns a typed table summary 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.data.totalCount).toBe(1) - expect(body.data.tables[0]).toMatchObject({ - id: 'tbl_1', - name: 'People', - description: 'A table', - rowCount: 5, - maxRows: 100, - createdAt: '2024-01-01T00:00:00.000Z', - }) - expect(body.data.tables[0].schema.columns[0].name).toBe('name') + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockListTables).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(mockListTables).not.toHaveBeenCalled() }) - it('surfaces an access-denied response from the middleware', async () => { - const { NextResponse } = await import('next/server') - mockValidateWorkspaceAccess.mockResolvedValue( - NextResponse.json({ error: 'Access denied' }, { status: 403 }) - ) + 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(mockListTables).not.toHaveBeenCalled() }) it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue({ allowed: false }) + mockCheckRateLimit.mockResolvedValue({ + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, + }) const res = await callList('workspaceId=workspace-1') expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=workspace-1&sortBy=name);--`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=workspace-1&sortOrder=sideways`) + + expect(res.status).toBe(400) + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=workspace-1&search=`) + + expect(res.status).toBe(400) + }) + + it('forwards search and sort into the query and still terminates pagination', async () => { + const res = await callList(`workspaceId=workspace-1&search=report&sortBy=name&sortOrder=asc`) + + expect(res.status).toBe(200) + expect((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 be0c59c32b6..1fcafeff9fd 100644 --- a/apps/sim/app/api/v2/tables/route.ts +++ b/apps/sim/app/api/v2/tables/route.ts @@ -1,78 +1,140 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { v2ListTablesContract } from '@/lib/api/contracts/v2/tables' -import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateTableContract, v2ListTablesContract } from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { listTables, type TableSchema } from '@/lib/table' -import { normalizeColumn, tablesV2GateError } from '@/app/api/table/utils' +import { 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 { - checkRateLimit, - createRateLimitResponse, - validateWorkspaceAccess, -} from '@/app/api/v1/middleware' + v2CaughtOrchestrationError, + 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 -/** Filters/ids can appear in query strings; keep list responses out of shared caches. */ -const PRIVATE_NO_STORE = { 'Cache-Control': 'private, no-store' } as const - -/** GET /api/v2/tables — list all tables in a workspace. */ +/** GET /api/v2/tables — List all tables in a workspace. */ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const rateLimit = await checkRateLimit(request, 'v2-tables') - if (!rateLimit.allowed) return createRateLimitResponse(rateLimit) + const rateLimit = await checkRateLimit(request, 'tables') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListTablesContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + 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, { folderId, search, sortBy, sortOrder }) + 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(v2ListTablesContract, request, {}) + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateTableContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const params = parsed.data.body - const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId) - if (accessError) return accessError + const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) - // After authz: the gate reads the workspace's org off the primary DB, and its - // 404 would otherwise distinguish "not in the rollout cohort" from "no access". - const gateError = await tablesV2GateError(userId, workspaceId) - if (gateError) return gateError + const planLimits = await getWorkspaceTableLimits(params.workspaceId) - const tables = await listTables(workspaceId) + const normalizedSchema: TableSchema = { + columns: params.schema.columns.map(normalizeColumn), + } - return NextResponse.json( + const table = await createTable( { - success: true, - data: { - tables: tables.map((t) => { - const schemaData = t.schema as TableSchema - return { - id: t.id, - name: t.name, - description: t.description, - schema: { columns: schemaData.columns.map(normalizeColumn) }, - rowCount: t.rowCount, - maxRows: t.maxRows, - createdAt: - t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt), - updatedAt: - t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt), - } - }), - totalCount: tables.length, - }, + name: params.name, + description: params.description, + schema: normalizedSchema, + workspaceId: params.workspaceId, + userId, + maxTables: planLimits.maxTables, }, - { headers: PRIVATE_NO_STORE } + requestId ) + + recordAudit({ + workspaceId: params.workspaceId, + actorId: userId, + action: AuditAction.TABLE_CREATED, + resourceType: AuditResourceType.TABLE, + resourceId: table.id, + resourceName: table.name, + description: `Created table "${table.name}" via API`, + metadata: { columnCount: params.schema.columns.length }, + request, + }) + + return v2Data({ table: toApiTable(table) }, { rateLimit, status: 201 }) } catch (error) { - const validationResponse = validationErrorResponseFromError(error) - if (validationResponse) return validationResponse + if (isZodError(error)) return v2ValidationError(error) + + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified - logger.error(`[${requestId}] Error listing tables:`, error) - return NextResponse.json({ error: 'Failed to list tables' }, { status: 500 }) + logger.error(`[${requestId}] Error creating table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') } }) diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts new file mode 100644 index 00000000000..8d662be3d4a --- /dev/null +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -0,0 +1,134 @@ +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, + validateStoragePredicate, +} from '@/lib/table/query-builder/validate' +import { predicateToStorage } from '@/lib/table/select-values' +import type { Filter } from '@/lib/table/types' +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) +} + +/** + * Resolves a public v2 bulk-op predicate to the storage-id-keyed legacy `Filter` + * the row runners consume. The public wire is column-NAME-keyed: shape-check + * first (keying-agnostic), translate names → storage ids (including select + * operand names → option ids), then validate the RESULT against storage keys — + * on a destructive path an unresolved field must 400, not silently match + * nothing. + */ +export function v2BulkPredicateToFilter(predicate: TablePredicate, schema: TableSchema): Filter { + validatePredicateShape(predicate) + const translated = predicateToStorage(predicate, schema) + validateStoragePredicate(translated, schema.columns) + return predicateToFilter(translated) +} + +/** + * Normalized public table shape — the same subset of fields the v1 surface + * exposes, with timestamps serialized to ISO strings. Shared by every v2 table + * endpoint so the table payload is identical across the surface. + */ +export function toApiTable(table: TableDefinition) { + 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 + createdAt: Date | string + updatedAt: Date | string +} + +/** + * Normalized public row shape: `{ id, data, createdAt, updatedAt }`, no storage + * internals (`position`/`orderKey`/`executions`). Callers pass a + * `namedRowMapper(schema.columns)` so `data` is keyed by column NAME and select + * cells surface their option NAME rather than the stored option id. + */ +export function toApiRow(row: ApiRowInput, toNamedRow: (data: RowData) => RowData) { + return { + id: row.id, + data: toNamedRow(row.data), + createdAt: toIso(row.createdAt), + updatedAt: toIso(row.updatedAt), + } +} + +/** + * Renders a failed {@link checkAccess} result on a MUTATION path: a missing + * table stays 404, a missing permission stays 403. Read paths instead mask both + * as 404 inline so cross-workspace resource existence is never leaked. + */ +export function v2TableAccessError(result: { ok: false; status: 404 | 403 }): NextResponse { + return result.status === 404 + ? v2Error('NOT_FOUND', 'Table not found') + : v2Error('FORBIDDEN', 'Access denied') +} + +/** + * Maps a delete/write rejected by a table lock to the v2 `LOCKED` envelope, + * mirroring v1's {@link tableLockErrorResponse}. Returns `null` for anything + * else so the caller falls through to its own classification. + */ +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 + * 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..ec2479d09b8 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts @@ -0,0 +1,176 @@ +import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v1DeployWorkflowBodySchema } from '@/lib/api/contracts/v1/workflows' +import { + v2DeployWorkflowContract, + v2UndeployWorkflowContract, +} from '@/lib/api/contracts/v2/workflows' +import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowDeployAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 120 + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'workflow-deploy') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeployWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const rawBody = await parseOptionalJsonBody(request) + if (!rawBody.success) { + return rawBody.response.status === 413 + ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') + : v2Error('BAD_REQUEST', 'Request body must be valid JSON') + } + const body = v1DeployWorkflowBodySchema.safeParse(rawBody.data ?? {}) + if (!body.success) return v2ValidationError(body.error) + + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found') + const { workspaceId } = target + + await assertWorkflowMutable(id) + + logger.info(`[${requestId}] Deploying workflow ${id} via v2 API`, { userId }) + + const result = await performFullDeploy({ + workflowId: id, + userId, + versionName: body.data.name, + versionDescription: body.data.description ?? undefined, + requestId, + }) + + if (!result.success) { + const code = + result.errorCode === 'not_found' + ? 'NOT_FOUND' + : result.errorCode === 'validation' + ? 'BAD_REQUEST' + : 'INTERNAL_ERROR' + return v2Error(code, result.error || 'Failed to deploy workflow') + } + + captureServerEvent( + userId, + 'workflow_deployed', + { workflow_id: id, workspace_id: workspaceId }, + { + groups: { workspace: workspaceId }, + setOnce: { first_workflow_deployed_at: new Date().toISOString() }, + } + ) + + return v2Data( + { + id, + isDeployed: true, + deployedAt: result.deployedAt?.toISOString() ?? null, + version: result.version, + warnings: result.warnings ?? [], + }, + { rateLimit } + ) + } catch (error) { + if (error instanceof WorkflowLockedError) { + return v2Error('LOCKED', error.message) + } + logger.error(`[${requestId}] Workflow deploy error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +export const DELETE = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'workflow-deploy') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UndeployWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found') + const { workflow, workspaceId } = target + + if (!workflow.isDeployed) { + return v2Error('BAD_REQUEST', 'Workflow is not deployed') + } + + await assertWorkflowMutable(id) + + logger.info(`[${requestId}] Undeploying workflow ${id} via v2 API`, { userId }) + + const result = await performFullUndeploy({ workflowId: id, userId, requestId }) + if (!result.success) { + return v2Error('INTERNAL_ERROR', result.error || 'Failed to undeploy workflow') + } + + captureServerEvent( + userId, + 'workflow_undeployed', + { workflow_id: id, workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + + return v2Data( + { + id, + isDeployed: false, + deployedAt: null, + warnings: result.warnings ?? [], + }, + { rateLimit } + ) + } catch (error) { + if (error instanceof WorkflowLockedError) { + return v2Error('LOCKED', error.message) + } + logger.error(`[${requestId}] Workflow undeploy error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts new file mode 100644 index 00000000000..757b09e7388 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts @@ -0,0 +1,404 @@ +/** + * @vitest-environment node + */ + +import { + createMockRequest, + dbChainMockFns, + executionPreprocessingMock, + executionPreprocessingMockFns, + loggingSessionMock, + resetDbChainMock, + setEnv, + workflowAuthzMockFns, + workflowsPersistenceUtilsMock, + workflowsPersistenceUtilsMockFns, + workflowsUtilsMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAuthenticateV1Request, + mockClaimExecutionId, + mockEnqueue, + mockExecuteWorkflowCore, + mockGenerateId, + mockGetWorkspaceBillingSettings, + mockHasDurableExecutionOwner, + mockReleaseExecutionIdClaim, + mockReleaseExecutionSlot, + mockValidatePublicApiAllowed, +} = vi.hoisted(() => ({ + mockAuthenticateV1Request: vi.fn(), + mockClaimExecutionId: vi.fn(), + mockEnqueue: vi.fn().mockResolvedValue('workflow-execution:execution-123'), + mockExecuteWorkflowCore: vi.fn(), + mockGenerateId: vi.fn(() => 'execution-123'), + mockGetWorkspaceBillingSettings: vi.fn(), + mockHasDurableExecutionOwner: vi.fn(), + mockReleaseExecutionIdClaim: vi.fn(), + mockReleaseExecutionSlot: vi.fn(), + mockValidatePublicApiAllowed: vi.fn(), +})) + +vi.mock('@/app/api/v1/auth', () => ({ + authenticateV1Request: mockAuthenticateV1Request, +})) + +vi.mock('@/lib/billing/calculations/usage-reservation', () => ({ + releaseExecutionSlot: mockReleaseExecutionSlot, +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBillingSettings: mockGetWorkspaceBillingSettings, +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + PublicApiNotAllowedError: class PublicApiNotAllowedError extends Error {}, + validatePublicApiAllowed: mockValidatePublicApiAllowed, +})) + +vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) +vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock) +vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock) +vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock) + +vi.mock('@/lib/workflows/executor/execution-core', () => ({ + executeWorkflowCore: mockExecuteWorkflowCore, +})) + +vi.mock('@/lib/workflows/executor/pause-persistence', () => ({ + handlePostExecutionPauseState: vi.fn(), +})) + +vi.mock('@/lib/workflows/executor/execution-id-claim', () => ({ + claimExecutionId: mockClaimExecutionId, + hasDurableExecutionOwner: mockHasDurableExecutionOwner, + releaseExecutionIdClaim: mockReleaseExecutionIdClaim, +})) + +vi.mock('@/lib/core/async-jobs', () => ({ + getJobQueue: vi.fn().mockResolvedValue({ + enqueue: mockEnqueue, + startJob: vi.fn(), + completeJob: vi.fn(), + markJobFailed: vi.fn(), + }), + shouldExecuteInline: vi.fn().mockReturnValue(false), +})) + +vi.mock('@/background/workflow-execution', () => ({ + executeWorkflowJob: vi.fn(), +})) + +vi.mock('@/lib/workflows/custom-blocks/operations', () => ({ + getCustomBlockRowsForWorkspace: vi.fn().mockResolvedValue([]), +})) + +vi.mock('@/blocks/custom/server-overlay', () => ({ + withCustomBlockOverlay: vi.fn(async (_rows: unknown, fn: () => unknown) => fn()), +})) + +vi.mock('@/serializer', () => ({ + Serializer: class { + serializeWorkflow() { + return { blocks: [] } + } + }, +})) + +vi.mock('@/lib/execution/files', () => ({ + processInputFileFields: vi.fn(async (input: unknown) => input), +})) + +vi.mock('@/lib/uploads/utils/user-file-base64.server', () => ({ + hydrateUserFilesWithBase64: vi.fn(async (output: unknown) => output), +})) + +vi.mock('@/lib/execution/payloads/serializer', () => ({ + compactExecutionPayload: vi.fn(async (value: unknown) => value), +})) + +vi.mock(import('@/lib/execution/payloads/large-value-ref'), async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, containsLargeValueRef: vi.fn().mockReturnValue(false) } +}) + +vi.mock('@sim/utils/id', () => ({ + generateId: mockGenerateId, + generateShortId: vi.fn(() => 'mock-short-id'), + isValidUuid: vi.fn((v: string) => + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v) + ), +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { attachExecutionResult } from '@/executor/utils/errors' +import { POST } from './route' + +const mockPreprocessExecution = executionPreprocessingMockFns.mockPreprocessExecution +const mockAuthorize = workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission +const mockLoadDeployedWorkflowState = workflowsPersistenceUtilsMockFns.mockLoadDeployedWorkflowState + +const billingAttribution = { + actorUserId: 'actor-1', + workspaceId: 'workspace-1', + organizationId: null, + billedAccountUserId: 'actor-1', + billingEntity: { type: 'user' as const, id: 'actor-1' }, + billingPeriod: { + start: '2026-07-01T00:00:00.000Z', + end: '2026-08-01T00:00:00.000Z', + }, + payerSubscription: null, +} + +const workflowRecord = { + id: 'workflow-1', + userId: 'owner-1', + workspaceId: 'workspace-1', + isDeployed: true, + variables: {}, +} + +function callExecute(body: Record, headers: Record = {}) { + const req = createMockRequest('POST', body, { + 'Content-Type': 'application/json', + ...headers, + }) + return POST(req, { params: Promise.resolve({ id: 'workflow-1' }) }) +} + +describe('POST /api/v2/workflows/[id]/execute', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + setEnv({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) + mockGenerateId.mockReturnValue('execution-123') + mockAuthenticateV1Request.mockResolvedValue({ + authenticated: true, + userId: 'key-user-1', + keyType: 'workspace', + workspaceId: 'workspace-1', + }) + mockAuthorize.mockResolvedValue({ allowed: true, workflow: workflowRecord }) + mockClaimExecutionId.mockImplementation(async (executionId: string) => ({ + key: `workflow-execution-id:${executionId}`, + token: `token-${executionId}`, + })) + mockHasDurableExecutionOwner.mockResolvedValue(false) + mockPreprocessExecution.mockResolvedValue({ + success: true, + actorUserId: 'actor-1', + workflowRecord, + actorSubscription: { plan: 'pro' }, + billingAttribution, + executionTimeout: { sync: 60_000, async: 300_000 }, + }) + mockLoadDeployedWorkflowState.mockResolvedValue({ + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + }) + mockExecuteWorkflowCore.mockResolvedValue({ + success: true, + output: { result: 'done' }, + metadata: { + duration: 42, + startTime: '2026-07-31T00:00:00.000Z', + endTime: '2026-07-31T00:00:01.000Z', + }, + }) + }) + + it('runs sync and returns the execution resource in the v2 envelope', async () => { + const res = await callExecute({ input: { hello: 'world' } }) + + expect(res.status).toBe(200) + expect(res.headers.get('X-Execution-Id')).toBe('execution-123') + const body = await res.json() + expect(body.data).toMatchObject({ + executionId: 'execution-123', + workflowId: 'workflow-1', + status: 'completed', + output: { result: 'done' }, + error: null, + durationMs: 42, + }) + }) + + it('returns status failed with a structured error instead of an HTTP error', async () => { + const error = new Error('Send Email: Invalid credentials') + Object.assign(error, { blockId: 'block-9', blockName: 'Send Email', blockType: 'gmail' }) + attachExecutionResult(error, { + success: false, + output: { partial: true }, + metadata: { duration: 10, startTime: 's', endTime: 'e' }, + }) + mockExecuteWorkflowCore.mockRejectedValue(error) + + const res = await callExecute({ input: {} }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.status).toBe('failed') + expect(body.data.executionId).toBe('execution-123') + expect(body.data.output).toEqual({ partial: true }) + expect(body.data.error).toEqual({ + message: 'Invalid credentials', + code: 'BLOCK_EXECUTION_FAILED', + blockId: 'block-9', + blockName: 'Send Email', + blockType: 'gmail', + }) + }) + + it('queues async runs and returns a 202 receipt with the v2 executions statusUrl', async () => { + const res = await callExecute({ input: {}, async: true }) + + expect(res.status).toBe(202) + const body = await res.json() + expect(body.data).toEqual({ + executionId: 'execution-123', + statusUrl: 'http://localhost:3000/api/v2/workflows/workflow-1/executions/execution-123', + }) + expect(mockPreprocessExecution).toHaveBeenCalledWith( + expect.objectContaining({ rateLimitCounter: 'async' }) + ) + }) + + it('404s the whole surface when the v2-api flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callExecute({ input: {} }) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockPreprocessExecution).not.toHaveBeenCalled() + }) + + it('rejects unknown body keys (strict contract)', async () => { + const res = await callExecute({ input: {}, triggerType: 'manual' }) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPreprocessExecution).not.toHaveBeenCalled() + }) + + it('rejects async combined with stream or output-shaping options', async () => { + expect((await callExecute({ async: true, stream: true })).status).toBe(400) + expect((await callExecute({ async: true, selectedOutputs: ['a.b'] })).status).toBe(400) + expect((await callExecute({ async: true, includeFileBase64: true })).status).toBe(400) + expect(mockPreprocessExecution).not.toHaveBeenCalled() + }) + + it('masks a workspace-key/workflow mismatch as 404', async () => { + mockAuthenticateV1Request.mockResolvedValue({ + authenticated: true, + userId: 'key-user-1', + keyType: 'workspace', + workspaceId: 'other-workspace', + }) + + const res = await callExecute({ input: {} }) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('rejects personal keys when the workspace disallows them', async () => { + mockAuthenticateV1Request.mockResolvedValue({ + authenticated: true, + userId: 'key-user-1', + keyType: 'personal', + }) + mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: false }) + + const res = await callExecute({ input: {} }) + + expect(res.status).toBe(403) + }) + + it('returns 409 CONFLICT for a reused X-Execution-Id', async () => { + mockClaimExecutionId.mockResolvedValue(null) + + const res = await callExecute( + { input: {} }, + { 'X-Execution-Id': '11111111-1111-4111-8111-111111111111' } + ) + + expect(res.status).toBe(409) + const body = await res.json() + expect(body.error.code).toBe('CONFLICT') + expect(body.error.details).toMatchObject({ + code: 'EXECUTION_ID_CONFLICT', + executionId: '11111111-1111-4111-8111-111111111111', + }) + }) + + it('surfaces the rate-limit failure with Retry-After', async () => { + mockPreprocessExecution.mockResolvedValue({ + success: false, + error: { + message: 'Rate limit exceeded. Please try again later.', + statusCode: 429, + code: 'RATE_LIMIT_EXCEEDED', + retryAfterMs: 12_000, + }, + }) + + const res = await callExecute({ input: {} }) + + expect(res.status).toBe(429) + expect(res.headers.get('Retry-After')).toBe('12') + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('runs the anonymous public path sync but refuses async', async () => { + mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' }) + dbChainMockFns.limit.mockResolvedValueOnce([ + { isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' }, + ]) + + const okRes = await callExecute({ input: {} }) + expect(okRes.status).toBe(200) + + mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' }) + dbChainMockFns.limit.mockResolvedValueOnce([ + { isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' }, + ]) + const asyncRes = await callExecute({ input: {}, async: true }) + expect(asyncRes.status).toBe(400) + }) + + it('401s non-public workflows without a key', async () => { + mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' }) + dbChainMockFns.limit.mockResolvedValueOnce([ + { isPublicApi: false, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' }, + ]) + + const res = await callExecute({ input: {} }) + + expect(res.status).toBe(401) + expect((await res.json()).error.code).toBe('UNAUTHORIZED') + }) + + it('releases the unused execution-id claim after a failed preprocess', async () => { + mockPreprocessExecution.mockResolvedValue({ + success: false, + error: { message: 'Workflow not found', statusCode: 404 }, + }) + + const res = await callExecute({ input: {} }) + + expect(res.status).toBe(404) + expect(mockReleaseExecutionIdClaim).toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts new file mode 100644 index 00000000000..ebe26b9ac2e --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts @@ -0,0 +1,290 @@ +import { db } from '@sim/db' +import { workflow as workflowTable } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { v2ExecuteWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { executionIdSchema, WORKFLOW_EXECUTION_ID_HEADER } from '@/lib/api/contracts/workflows' +import { parseRequest } from '@/lib/api/server' +import { tryAdmit } from '@/lib/core/admission/gate' +import { ADMISSION_ERROR_DESCRIPTOR } from '@/lib/core/admission/transient-failure' +import { generateRequestId } from '@/lib/core/utils/request' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + type ExecuteWorkflowServiceFailure, + executeWorkflowService, +} from '@/lib/workflows/executor/execute-service' +import { + AGENT_STREAM_PROTOCOL_HEADER_LABEL, + AGENT_STREAM_PROTOCOL_V1, + clientAcceptsAgentStreamProtocol, + hasAgentStreamPolicy, +} from '@/lib/workflows/streaming/agent-stream-protocol' +import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils' +import { authenticateV1Request } from '@/app/api/v1/auth' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { type V2ErrorCode, v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' +import { + PublicApiNotAllowedError, + validatePublicApiAllowed, +} from '@/ee/access-control/utils/permission-check' + +const logger = createLogger('V2WorkflowExecuteAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const FAILURE_CODE_BY_STATUS: Record = { + 400: 'BAD_REQUEST', + 401: 'UNAUTHORIZED', + 402: 'USAGE_LIMIT_EXCEEDED', + 403: 'FORBIDDEN', + 404: 'NOT_FOUND', + 408: 'BAD_REQUEST', + 409: 'CONFLICT', + 413: 'PAYLOAD_TOO_LARGE', + 429: 'RATE_LIMITED', + 499: 'CLIENT_CLOSED_REQUEST', + 503: 'SERVICE_UNAVAILABLE', +} + +function serviceFailureResponse(failure: ExecuteWorkflowServiceFailure) { + const code = FAILURE_CODE_BY_STATUS[failure.statusCode] ?? 'INTERNAL_ERROR' + const headers: Record = {} + if (failure.retryAfterMs !== undefined) { + headers['Retry-After'] = Math.max(1, Math.ceil(failure.retryAfterMs / 1000)).toString() + } + if (failure.executionId) { + headers[WORKFLOW_EXECUTION_ID_HEADER] = failure.executionId + } + return v2Error(code, failure.message, { + status: failure.statusCode, + headers, + details: + failure.code || failure.executionId + ? { + ...(failure.code ? { code: failure.code } : {}), + ...(failure.executionId ? { executionId: failure.executionId } : {}), + } + : undefined, + }) +} + +/** + * POST /api/v2/workflows/[id]/execute — syntactic sugar over + * {@link executeWorkflowService}. + * + * - Auth: `X-API-Key` (personal/workspace) or the anonymous public-API path for + * workflows deployed with `isPublicApi` (actor = owner; sync/stream only). + * - `async: true` (body flag — v2 has no mode headers) → 202 + * `{ data: { executionId, statusUrl } }`; poll the v2 executions resource. + * - `stream: true` → SSE passthrough (no `{data}` envelope on event frames). + * - Sync → 200 execution resource with the status enum and structured error; + * an in-band run failure is `status: 'failed'`, never an HTTP error. A + * Response block's declared payload stays inside `output` — v2 never lets a + * workflow author control response status or headers on this origin. + * - Rate limiting: the execution `sync`/`async` buckets via preprocessing — + * deliberately NOT the shared `api-endpoint` bucket, and async runs debit + * the async bucket (unlike v1's known sync-bucket bug). + */ +export const POST = withRouteHandler( + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id: workflowId } = await context.params + + let userId: string + let isPublicApiAccess = false + let apiKeyType: 'personal' | 'workspace' | undefined + let apiKeyWorkspaceId: string | undefined + + const auth = await authenticateV1Request(req) + if (auth.authenticated && auth.userId) { + userId = auth.userId + apiKeyType = auth.keyType + apiKeyWorkspaceId = auth.workspaceId + } else { + if (req.headers.has('x-api-key')) { + return v2Error('UNAUTHORIZED', auth.error || 'Unauthorized') + } + const [wf] = await db + .select({ + isPublicApi: workflowTable.isPublicApi, + isDeployed: workflowTable.isDeployed, + userId: workflowTable.userId, + workspaceId: workflowTable.workspaceId, + }) + .from(workflowTable) + .where(eq(workflowTable.id, workflowId)) + .limit(1) + + if (!wf?.isPublicApi || !wf.isDeployed || !wf.workspaceId) { + return v2Error('UNAUTHORIZED', 'Unauthorized') + } + try { + await validatePublicApiAllowed(wf.userId, wf.workspaceId) + } catch (err) { + if (err instanceof PublicApiNotAllowedError) { + return v2Error('UNAUTHORIZED', 'Unauthorized') + } + throw err + } + userId = wf.userId + isPublicApiAccess = true + } + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const ticket = tryAdmit() + if (!ticket) { + return v2Error('RATE_LIMITED', 'Server is at capacity. Please retry shortly.', { + headers: { + 'Retry-After': ADMISSION_ERROR_DESCRIPTOR.GATE_CAPACITY.retryAfterSeconds.toString(), + }, + }) + } + + try { + const parsed = await parseRequest(v2ExecuteWorkflowContract, req, context, { + maxBodyBytes: 10 * 1024 * 1024, + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const body = parsed.data.body + + if (body.async && isPublicApiAccess) { + return v2Error('BAD_REQUEST', 'Async execution requires an API key') + } + if (body.async && body.stream) { + return v2Error('BAD_REQUEST', 'async and stream cannot be combined') + } + if ( + body.async && + (body.selectedOutputs?.length || + body.includeThinking || + body.includeToolCalls || + body.includeFileBase64 !== undefined || + body.base64MaxBytes !== undefined) + ) { + return v2Error( + 'BAD_REQUEST', + 'Async execution does not support streaming or output-shaping options' + ) + } + if ( + hasAgentStreamPolicy({ + includeThinking: body.includeThinking, + includeToolCalls: body.includeToolCalls, + }) && + !clientAcceptsAgentStreamProtocol(req.headers) + ) { + return v2Error( + 'BAD_REQUEST', + `includeThinking and includeToolCalls require the ${AGENT_STREAM_PROTOCOL_HEADER_LABEL}: ${AGENT_STREAM_PROTOCOL_V1} request header, which declares that the client understands agent-event frames.` + ) + } + + /** Idempotent execution ids are a keyed-caller feature; anonymous callers must not probe the claim table. */ + let requestedExecutionId: string | undefined + const executionIdHeader = req.headers.get(WORKFLOW_EXECUTION_ID_HEADER) + if (executionIdHeader !== null && !isPublicApiAccess) { + const headerValidation = executionIdSchema.safeParse(executionIdHeader) + if (!headerValidation.success) { + return v2Error('BAD_REQUEST', 'Invalid execution ID header') + } + requestedExecutionId = headerValidation.data + } + + const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId, + action: 'read', + }) + // Mask authorization failures as 404 so cross-workspace existence never leaks. + if (!workflowAuthorization.allowed || !workflowAuthorization.workflow) { + return v2Error('NOT_FOUND', 'Workflow not found') + } + const workflowRecord = workflowAuthorization.workflow + + if (apiKeyType === 'workspace' && workflowRecord.workspaceId !== apiKeyWorkspaceId) { + return v2Error('NOT_FOUND', 'Workflow not found') + } + if (apiKeyType === 'personal' && workflowRecord.workspaceId) { + const settings = await getWorkspaceBillingSettings(workflowRecord.workspaceId) + if (!settings?.allowPersonalApiKeys) { + return v2Error('FORBIDDEN', 'Personal API keys are not allowed for this workspace') + } + } + + const result = await executeWorkflowService({ + workflowId, + userId, + input: body.input ?? {}, + triggerType: 'api', + requestId, + executionId: requestedExecutionId, + useAuthenticatedUserAsActor: apiKeyType === 'personal', + workflowRecord, + includeFileBase64: body.includeFileBase64, + base64MaxBytes: body.base64MaxBytes, + selectedOutputs: body.selectedOutputs, + rateLimitCounter: body.async ? 'async' : 'sync', + abortSignal: req.signal, + mode: body.async ? 'async' : body.stream ? 'stream' : 'sync', + requestHeaders: req.headers, + includeThinking: body.includeThinking, + includeToolCalls: body.includeToolCalls, + }) + + if (!result.ok) { + return serviceFailureResponse(result.failure) + } + + if ('stream' in result) { + // SSE: pass the stream through byte-for-byte with its own headers. + return result.stream + } + + if ('queued' in result) { + return v2Data( + { + executionId: result.executionId, + statusUrl: `${getBaseUrl()}/api/v2/workflows/${workflowId}/executions/${result.executionId}`, + }, + { status: 202, headers: { [WORKFLOW_EXECUTION_ID_HEADER]: result.executionId } } + ) + } + + if (result.aborted === 'client') { + return v2Error('CLIENT_CLOSED_REQUEST', 'Client cancelled request', { + details: { executionId: result.executionId }, + }) + } + + return v2Data( + { + executionId: result.executionId, + workflowId: result.workflowId, + status: result.status, + output: result.output ?? null, + error: result.error, + startedAt: result.startedAt, + endedAt: result.endedAt, + durationMs: result.durationMs, + }, + { headers: { [WORKFLOW_EXECUTION_ID_HEADER]: result.executionId } } + ) + } catch (error) { + logger.error(`[${requestId}] v2 execute failed`, { + workflowId, + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } finally { + ticket.release() + } + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/cancel/route.ts b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/cancel/route.ts new file mode 100644 index 00000000000..2d3ebe1166e --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/cancel/route.ts @@ -0,0 +1,48 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CancelWorkflowExecutionContract } from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { cancelWorkflowExecution } from '@/lib/execution/cancel-workflow-execution' +import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' +import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access' + +const logger = createLogger('V2CancelExecutionAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** POST /api/v2/workflows/[id]/executions/[executionId]/cancel */ +export const POST = withRouteHandler( + async (req: NextRequest, context: { params: Promise<{ id: string; executionId: string }> }) => { + const parsed = await parseRequest(v2CancelWorkflowExecutionContract, req, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { id: workflowId, executionId } = parsed.data.params + + const access = await resolveV2WorkflowAccess(req, workflowId, 'write') + if (!access.ok) return access.response + + try { + logger.info('Cancel execution requested', { workflowId, executionId, userId: access.userId }) + + const result = await cancelWorkflowExecution({ + executionId, + workflowId, + userId: access.userId, + workspaceId: access.workflow.workspaceId ?? undefined, + }) + + return v2Data(result) + } catch (error) { + logger.error('Failed to cancel execution', { + workflowId, + executionId, + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts new file mode 100644 index 00000000000..c0e96fc8080 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts @@ -0,0 +1,175 @@ +/** + * @vitest-environment node + */ +import { createMockRequest, workflowAuthzMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAuthenticateV1Request, mockGetJob, mockGetWorkflowExecutionStatus, mockCancel } = + vi.hoisted(() => ({ + mockAuthenticateV1Request: vi.fn(), + mockGetJob: vi.fn(), + mockGetWorkflowExecutionStatus: vi.fn(), + mockCancel: vi.fn(), + })) + +vi.mock('@/app/api/v1/auth', () => ({ + authenticateV1Request: mockAuthenticateV1Request, +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBillingSettings: vi.fn().mockResolvedValue({ allowPersonalApiKeys: true }), +})) + +vi.mock('@/lib/workflows/executor/execution-status', () => ({ + getWorkflowExecutionStatus: mockGetWorkflowExecutionStatus, +})) + +vi.mock('@/lib/execution/cancel-workflow-execution', () => ({ + cancelWorkflowExecution: mockCancel, +})) + +vi.mock('@/lib/core/async-jobs', () => ({ + getJobQueue: vi.fn().mockResolvedValue({ getJob: mockGetJob }), +})) + +vi.mock('@/lib/workflows/executor/enqueue-execution', () => ({ + WORKFLOW_EXECUTION_JOB_ID_PREFIX: 'workflow-execution:', +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { POST as cancelPost } from './cancel/route' +import { GET } from './route' + +const mockAuthorize = workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission + +const workflowRecord = { + id: 'workflow-1', + userId: 'owner-1', + workspaceId: 'workspace-1', +} + +function callStatus(query = '') { + const req = createMockRequest( + 'GET', + undefined, + {}, + `http://localhost:3000/api/v2/workflows/workflow-1/executions/exec-1${query}` + ) + return GET(req, { params: Promise.resolve({ id: 'workflow-1', executionId: 'exec-1' }) }) +} + +describe('v2 executions status + cancel', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAuthenticateV1Request.mockResolvedValue({ + authenticated: true, + userId: 'key-user-1', + keyType: 'workspace', + workspaceId: 'workspace-1', + }) + mockAuthorize.mockResolvedValue({ allowed: true, workflow: workflowRecord }) + }) + + it('returns the execution resource with a structured error', async () => { + mockGetWorkflowExecutionStatus.mockResolvedValue({ + executionId: 'exec-1', + workflowId: 'workflow-1', + status: 'failed', + trigger: 'api', + level: 'error', + startedAt: '2026-07-31T00:00:00.000Z', + endedAt: '2026-07-31T00:00:05.000Z', + totalDurationMs: 5000, + paused: null, + cost: { total: 0.02 }, + error: 'Send Email: Invalid credentials', + finalOutput: null, + blockOutputs: null, + }) + + const res = await callStatus() + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.status).toBe('failed') + expect(body.data.error.code).toBe('EXECUTION_FAILED') + expect(body.data.error.message).toBe('Send Email: Invalid credentials') + expect(body.data.durationMs).toBe(5000) + }) + + it('backfills queued status from the job queue before the log row exists', async () => { + mockGetWorkflowExecutionStatus.mockResolvedValue(null) + mockGetJob.mockResolvedValue({ + status: 'pending', + metadata: { workflowId: 'workflow-1' }, + }) + + const res = await callStatus() + + expect(res.status).toBe(200) + expect((await res.json()).data.status).toBe('queued') + expect(mockGetJob).toHaveBeenCalledWith('workflow-execution:exec-1') + }) + + it('404s when neither a log row nor a matching job exists', async () => { + mockGetWorkflowExecutionStatus.mockResolvedValue(null) + mockGetJob.mockResolvedValue(null) + + const res = await callStatus() + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('masks cross-workspace access as 404', async () => { + mockAuthenticateV1Request.mockResolvedValue({ + authenticated: true, + userId: 'key-user-1', + keyType: 'workspace', + workspaceId: 'other-workspace', + }) + + const res = await callStatus() + + expect(res.status).toBe(404) + expect(mockGetWorkflowExecutionStatus).not.toHaveBeenCalled() + }) + + it('cancels through the shared lib and returns the tightened result', async () => { + mockCancel.mockResolvedValue({ + success: true, + executionId: 'exec-1', + redisAvailable: true, + durablyRecorded: true, + locallyAborted: false, + pausedCancelled: false, + reason: 'recorded', + }) + + const req = createMockRequest('POST', undefined, {}) + const res = await cancelPost(req, { + params: Promise.resolve({ id: 'workflow-1', executionId: 'exec-1' }), + }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data).toMatchObject({ success: true, reason: 'recorded' }) + expect(mockCancel).toHaveBeenCalledWith({ + executionId: 'exec-1', + workflowId: 'workflow-1', + userId: 'key-user-1', + workspaceId: 'workspace-1', + }) + }) + + it('401s without an API key (no session/anonymous path on executions)', async () => { + mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' }) + + const res = await callStatus() + + expect(res.status).toBe(401) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.ts b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.ts new file mode 100644 index 00000000000..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/[id]/export/route.ts b/apps/sim/app/api/v2/workflows/[id]/export/route.ts new file mode 100644 index 00000000000..35ab0d287d6 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/export/route.ts @@ -0,0 +1,86 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { v2ExportWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { 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') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/workflows/[id]/export + * + * Exports a workflow as a portable JSON envelope that + * `POST /api/v2/workflows/import` accepts verbatim. Payload assembly and the + * sanitization guarantees are documented on the shared + * {@link buildWorkflowExportPayload}; this route authenticates and renders the + * v2 envelope. + */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflow-export') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ExportWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + logger.info(`[${requestId}] Exporting workflow ${id}`, { userId }) + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') + + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') + + const payload = await buildWorkflowExportPayload(workflowData) + if (!payload) return v2Error('NOT_FOUND', 'Workflow state not found') + + recordAudit({ + workspaceId: workflowData.workspaceId, + actorId: userId, + action: AuditAction.WORKFLOW_EXPORTED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: workflowData.id, + resourceName: workflowData.name, + description: `Exported workflow "${workflowData.name}" via the API`, + metadata: { + workspaceId: workflowData.workspaceId, + folderId: workflowData.folderId || undefined, + blocksCount: Object.keys(payload.state.blocks).length, + edgesCount: payload.state.edges.length, + }, + request, + }) + + return v2Data(payload, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Workflow export error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts new file mode 100644 index 00000000000..8d1cea75788 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts @@ -0,0 +1,125 @@ +import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v1RollbackWorkflowBodySchema } from '@/lib/api/contracts/v1/workflows' +import { v2RollbackWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { performActivateVersion } from '@/lib/workflows/orchestration' +import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowRollbackAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 120 + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'workflow-rollback') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2RollbackWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const rawBody = await parseOptionalJsonBody(request) + if (!rawBody.success) { + return rawBody.response.status === 413 + ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') + : v2Error('BAD_REQUEST', 'Request body must be valid JSON') + } + const body = v1RollbackWorkflowBodySchema.safeParse(rawBody.data ?? {}) + if (!body.success) return v2ValidationError(body.error) + + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found') + const { workflow, workspaceId } = target + + if (!workflow.isDeployed) { + return v2Error('BAD_REQUEST', 'Workflow is not deployed') + } + + await assertWorkflowMutable(id) + + let targetVersion = body.data.version + if (targetVersion === undefined) { + const previous = await findPreviousDeploymentVersion(id) + if (!previous.ok) { + const message = + previous.reason === 'no_active_version' + ? 'Workflow has no active deployment to roll back from' + : 'No previous deployment version to roll back to' + return v2Error('BAD_REQUEST', message) + } + targetVersion = previous.version + } + + logger.info( + `[${requestId}] Rolling back workflow ${id} to version ${targetVersion} via v2 API`, + { userId } + ) + + const result = await performActivateVersion({ + workflowId: id, + version: targetVersion, + userId, + requestId, + }) + + if (!result.success) { + const code = + result.errorCode === 'not_found' + ? 'NOT_FOUND' + : result.errorCode === 'validation' + ? 'BAD_REQUEST' + : 'INTERNAL_ERROR' + return v2Error(code, result.error || 'Failed to roll back workflow') + } + + captureServerEvent( + userId, + 'deployment_version_activated', + { workflow_id: id, workspace_id: workspaceId, version: targetVersion }, + { groups: { workspace: workspaceId } } + ) + + return v2Data( + { + id, + isDeployed: true, + deployedAt: result.deployedAt?.toISOString() ?? null, + version: targetVersion, + warnings: result.warnings ?? [], + }, + { rateLimit } + ) + } catch (error) { + if (error instanceof WorkflowLockedError) { + return v2Error('LOCKED', error.message) + } + logger.error(`[${requestId}] Workflow rollback error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/route.test.ts new file mode 100644 index 00000000000..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 new file mode 100644 index 00000000000..a3b22e05dc0 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -0,0 +1,253 @@ +import { db } from '@sim/db' +import { workflowBlocks } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +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, + 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, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowDetailAPI') + +export const revalidate = 0 + +interface RouteContext { + params: Promise<{ id: string }> +} + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflow-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') + + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') + + const 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') + } + } +) + +/** 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/import/route.ts b/apps/sim/app/api/v2/workflows/import/route.ts new file mode 100644 index 00000000000..af66621b16c --- /dev/null +++ b/apps/sim/app/api/v2/workflows/import/route.ts @@ -0,0 +1,112 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { v2ImportWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + importWorkflowIntoWorkspace, + MAX_IMPORT_BODY_BYTES, +} from '@/lib/workflows/operations/import-workflow' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + type V2ErrorCode, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowImportAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const ERROR_CODE_BY_STATUS: Record = { + 400: 'BAD_REQUEST', + 404: 'NOT_FOUND', + 409: 'CONFLICT', + 423: 'LOCKED', + 500: 'INTERNAL_ERROR', +} + +/** + * POST /api/v2/workflows/import + * + * Creates a new workflow in the target workspace from an export payload + * produced by `GET /api/v2/workflows/{id}/export`. The shared + * {@link importWorkflowIntoWorkspace} pipeline does the heavy lifting; this + * route authenticates and renders the v2 envelope. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflow-import') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ImportWorkflowContract, + request, + {}, + { + maxBodyBytes: MAX_IMPORT_BODY_BYTES, + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, folderId, name, description } = parsed.data.body + + logger.info(`[${requestId}] Importing workflow into workspace ${workspaceId}`, { + userId, + folderId, + }) + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await importWorkflowIntoWorkspace({ + workspaceId, + folderId, + name, + description, + workflow: parsed.data.body.workflow, + userId, + requestId, + }) + + if (!result.success) { + return v2Error(ERROR_CODE_BY_STATUS[result.status] ?? 'INTERNAL_ERROR', result.error, { + status: result.status, + details: result.details, + }) + } + + return v2Data( + { + id: result.workflow.id, + name: result.workflow.name, + description: result.workflow.description, + workspaceId: result.workflow.workspaceId, + folderId: result.workflow.folderId, + createdAt: result.workflow.createdAt.toISOString(), + updatedAt: result.workflow.updatedAt.toISOString(), + }, + { rateLimit, status: 201 } + ) + } catch (error) { + logger.error(`[${requestId}] Workflow import error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/workflows/lib/access.ts b/apps/sim/app/api/v2/workflows/lib/access.ts new file mode 100644 index 00000000000..404b820ac89 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/lib/access.ts @@ -0,0 +1,63 @@ +import type { workflow as workflowTable } from '@sim/db/schema' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' +import type { NextRequest, NextResponse } from 'next/server' +import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils' +import { authenticateV1Request } from '@/app/api/v1/auth' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { v2Error } from '@/app/api/v2/lib/response' + +type WorkflowRecord = typeof workflowTable.$inferSelect + +export type V2WorkflowAccess = + | { + ok: true + userId: string + keyType: 'personal' | 'workspace' | undefined + workflow: WorkflowRecord + } + | { ok: false; response: NextResponse } + +/** + * X-API-Key auth + workflow authorization for the v2 execution sub-resources. + * Authorization failures and workspace-key scope mismatches are masked as 404 + * so cross-workspace workflow existence never leaks; personal keys honor the + * workspace's `allowPersonalApiKeys` setting. + */ +export async function resolveV2WorkflowAccess( + request: NextRequest, + workflowId: string, + action: 'read' | 'write' +): Promise { + const auth = await authenticateV1Request(request) + if (!auth.authenticated || !auth.userId) { + return { ok: false, response: v2Error('UNAUTHORIZED', auth.error || 'Unauthorized') } + } + + const gate = await v2ApiGateError(auth.userId) + if (gate) return { ok: false, response: gate } + + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId: auth.userId, + action, + }) + if (!authorization.allowed || !authorization.workflow) { + return { ok: false, response: v2Error('NOT_FOUND', 'Workflow not found') } + } + const workflow = authorization.workflow as WorkflowRecord + + if (auth.keyType === 'workspace' && workflow.workspaceId !== auth.workspaceId) { + return { ok: false, response: v2Error('NOT_FOUND', 'Workflow not found') } + } + if (auth.keyType === 'personal' && workflow.workspaceId) { + const settings = await getWorkspaceBillingSettings(workflow.workspaceId) + if (!settings?.allowPersonalApiKeys) { + return { + ok: false, + response: v2Error('FORBIDDEN', 'Personal API keys are not allowed for this workspace'), + } + } + } + + return { ok: true, userId: auth.userId, keyType: auth.keyType, workflow } +} diff --git a/apps/sim/app/api/v2/workflows/route.test.ts b/apps/sim/app/api/v2/workflows/route.test.ts new file mode 100644 index 00000000000..6b3e0ee67c2 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/route.test.ts @@ -0,0 +1,424 @@ +/** + * @vitest-environment node + * + * Public v2 workflow list: the search/sort/filter convention, and the keyset + * cursor's binding to the sort it was minted under. The assertions look at the + * WHERE/ORDER BY the route hands drizzle, because that is the whole point of + * the change — a search must narrow the query, not the result. + */ +import { + dbChainMockFns, + flattenMockConditions, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockPerformCreateWorkflow, + mockAssertFolderMutable, + 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', () => ({ + checkRateLimit: mockCheckRateLimit, + 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, POST } from '@/app/api/v2/workflows/route' + +const WS = 'workspace-1' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +function buildRow(overrides: Record = {}) { + return { + id: 'wf_1', + name: 'Daily digest', + description: null, + folderId: null, + workspaceId: WS, + isDeployed: false, + deployedAt: null, + runCount: 3, + lastRunAt: null, + sortOrder: 0, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +const callList = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/workflows?${query}`)) + +/** The condition nodes the route passed to `.where()` on the last query. */ +const lastConditions = () => + flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]).filter(Boolean) + +const lastOrderBy = () => dbChainMockFns.orderBy.mock.calls.at(-1) ?? [] + +/** + * Timestamp keys order on `date_trunc('milliseconds', col)` rather than the raw + * column, so the mocked `sql` fragment carries the column in its interpolated + * values rather than being the column itself. + */ +const truncatedColumnOf = (entry: { column: { values?: unknown[] } }) => entry.column?.values?.[0] + +describe('GET /api/v2/workflows', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + }) + + 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() + }) +}) + +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 new file mode 100644 index 00000000000..88d9b457264 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -0,0 +1,258 @@ +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' +import type { NextRequest } from 'next/server' +import { + type V2WorkflowListItem, + type V2WorkflowSortBy, + v2CreateWorkflowContract, + v2ListWorkflowsContract, +} from '@/lib/api/contracts/v2/workflows' +import { + encodeKeyset, + type KeysetKey, + keysetAfter, + keysetColumns, + listOrderBy, + numberKey, + searchFilter, + textKey, + timestampKey, +} from '@/lib/api/list-query' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performCreateWorkflow } from '@/lib/workflows/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + cursorSortKey, + decodeSortedCursor, + encodeSortedCursor, + v2CursorList, + v2CursorSortError, + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +type WorkflowRow = { + id: string + name: string + sortOrder: number + runCount: number + createdAt: Date + updatedAt: Date +} + +/** + * The keysets behind the sortable workflow fields. `satisfies` makes the map + * total over the contract enum, so a new sortable field cannot ship without an + * ordering. Every key column is `NOT NULL` and each keyset ends in `id`, which + * is what keeps a page boundary inside a run of equal values stable. + * + * `position` keeps its historical three-part ordering: workflows share a + * `sortOrder` freely, and dropping `createdAt` from the tiebreak would reshuffle + * every workspace's default list. + */ +const workflowId = textKey(workflow.id, (row) => row.id) +const workflowCreatedAt = timestampKey(workflow.createdAt, (row) => row.createdAt) + +const WORKFLOW_SORTS = { + position: [ + numberKey(workflow.sortOrder, (row) => row.sortOrder), + workflowCreatedAt, + workflowId, + ], + name: [textKey(workflow.name, (row) => row.name), workflowId], + createdAt: [workflowCreatedAt, workflowId], + updatedAt: [timestampKey(workflow.updatedAt, (row) => row.updatedAt), workflowId], + runCount: [numberKey(workflow.runCount, (row) => row.runCount), workflowId], +} satisfies Record[]> + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListWorkflowsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const params = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const 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({ + id: workflow.id, + name: workflow.name, + description: workflow.description, + folderId: workflow.folderId, + workspaceId: workflow.workspaceId, + isDeployed: workflow.isDeployed, + deployedAt: workflow.deployedAt, + runCount: workflow.runCount, + lastRunAt: workflow.lastRunAt, + sortOrder: workflow.sortOrder, + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt, + }) + .from(workflow) + .where(and(...conditions)) + .orderBy(...listOrderBy(keysetColumns(keys), params.sortOrder)) + .limit(params.limit + 1) + + const hasMore = rows.length > params.limit + const data = rows.slice(0, params.limit) + + const last = data.at(-1) + const nextCursor = + hasMore && last ? encodeSortedCursor(sortKey, encodeKeyset(keys, last)) : null + + const formatted: V2WorkflowListItem[] = data.map((w) => ({ + id: w.id, + name: w.name, + description: w.description, + 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') + } +}) + +/** 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/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 4c7e1027161..4fb34919b76 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -7,6 +7,7 @@ import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { updatePublicApiContract } from '@/lib/api/contracts/deployments' import { parseRequest } from '@/lib/api/server' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' @@ -15,7 +16,6 @@ import { performFullDeploy, performFullUndeploy, } from '@/lib/workflows/orchestration' -import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { checkNeedsRedeployment, diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts index d3c3337e62f..5d5300ec13d 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts @@ -4,10 +4,10 @@ import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { updateDeploymentVersionMetadataContract } from '@/lib/api/contracts/deployments' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performActivateVersion } from '@/lib/workflows/orchestration' -import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { getWorkflowDeploymentVersion, updateDeploymentVersionMetadata, diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index 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..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 } ), @@ -730,6 +594,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( 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/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/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 2c9f36ab1db..8ab3b12ad25 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/executor/handlers/workflow/custom-block-tool-runner.ts b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts index ce8ca3b67c8..8db144c395d 100644 --- a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts +++ b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts @@ -10,7 +10,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..9596b039cca --- /dev/null +++ b/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts @@ -0,0 +1,104 @@ +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 { 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') + +/** + * 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 + _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/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 b5a509fbf6c..90bd6a11b1b 100644 --- a/apps/sim/executor/utils/errors.ts +++ b/apps/sim/executor/utils/errors.ts @@ -131,3 +131,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/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/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/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/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/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/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/v1/admin/organizations.ts b/apps/sim/lib/api/contracts/v1/admin/organizations.ts index 192415b06da..af86a5f40c1 100644 --- a/apps/sim/lib/api/contracts/v1/admin/organizations.ts +++ b/apps/sim/lib/api/contracts/v1/admin/organizations.ts @@ -147,7 +147,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(), @@ -190,8 +191,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/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/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/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/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts new file mode 100644 index 00000000000..7c411ec532d --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -0,0 +1,241 @@ +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, + v2SearchSchema, + v2SortFields, +} 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 + +/** 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 + +/** 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..c2e6221e776 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/custom-tools.ts @@ -0,0 +1,165 @@ +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, + v2SearchSchema, + v2SortFields, +} 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 + +/** 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, + 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: v2ListCustomToolsQuerySchema, + 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/files.ts b/apps/sim/lib/api/contracts/v2/files.ts new file mode 100644 index 00000000000..fc7f81c0697 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -0,0 +1,404 @@ +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, + v2SearchSchema, + v2SortFields, +} 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. + * + * 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. */ +export const v2FileSchema = z.object({ + id: z.string(), + name: z.string(), + 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 + +/** 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 + +/** 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' + ) + +/** Sortable file fields. `name` is the uploaded file name, not the storage key. */ +export const v2FileSortFields = ['name', 'size', 'uploadedAt', 'updatedAt'] as const + +export type V2FileSortBy = (typeof v2FileSortFields)[number] + +/** + * List query: workspace scope, the v2 search/sort convention, an optional + * folder filter, and opaque keyset cursor pagination. `limit` clamps to + * `[1, 1000]` (default 100) to bound the response. + * + * The keyset is `(, 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() + .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. + * `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 + +/** 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 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', + 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 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]', + params: v2FileParamsSchema, + query: v2FileWorkspaceQuerySchema, + response: { + mode: 'json', + 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/api/contracts/v2/folders.ts b/apps/sim/lib/api/contracts/v2/folders.ts new file mode 100644 index 00000000000..e59abdbdab1 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/folders.ts @@ -0,0 +1,199 @@ +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, + v2SearchSchema, + v2SortFields, +} 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 + +/** + * 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 + +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/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts new file mode 100644 index 00000000000..d92f30c20a1 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -0,0 +1,294 @@ +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, + v2SearchSchema, + v2SortFields, +} 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 + +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: v2ListKnowledgeBasesQuerySchema, + 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/mcp-servers.ts b/apps/sim/lib/api/contracts/v2/mcp-servers.ts new file mode 100644 index 00000000000..96b40e38b13 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/mcp-servers.ts @@ -0,0 +1,233 @@ +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, + v2SearchSchema, + v2SortFields, +} 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 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, + 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: v2ListMcpServersQuerySchema, + 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/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts new file mode 100644 index 00000000000..02a10f692d9 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -0,0 +1,108 @@ +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. + * + * ## 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. */ +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(), + }) + +/** + * 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 new file mode 100644 index 00000000000..003151aef9f --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/skills.ts @@ -0,0 +1,172 @@ +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, + v2SearchSchema, + v2SortFields, +} 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 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, + 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: v2ListSkillsQuerySchema, + 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/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts new file mode 100644 index 00000000000..fc9d4046979 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -0,0 +1,434 @@ +import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { + createTableColumnBodySchema, + deleteTableColumnBodySchema, + predicateSchema, + sortSpecSchema, + tableColumnSchema, + tableIdParamsSchema, + tableRowParamsSchema, + tableRowsQueryBaseSchema, + updateRowsByFilterBodySchema, + updateTableColumnBodySchema, + updateTableRowBodySchema, + upsertTableRowBodySchema, +} from '@/lib/api/contracts/tables' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v1CreateTableBodySchema, + v1CreateTableRowsBodySchema, + v1ListTablesQuerySchema, +} from '@/lib/api/contracts/v1/tables' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' +import { TABLE_LIMITS } from '@/lib/table/constants' + +/** + * v2 tables contracts. + * + * Request shapes (params/query/body) are reused from the v1 contract and the + * first-party `/api/table` contract where the surface is unchanged. Two things + * are upgraded relative to v1: + * + * - The response envelope is the canonical v2 family (`{ data }` for + * single/mutation, `{ data, nextCursor }` for lists). + * - Filters speak ONLY the typed predicate tree (`{ all | any: [...] }` — the + * same grammar the table_v2 block consumes). The legacy MongoDB-style + * `$`-operator object stays a v1-only dialect. Rich filtered reads live on + * the dedicated `POST /query` endpoint; the rows GET is a plain cursor page. + * + * 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`. + */ + +/** Default page size when a row query/list `limit` is omitted. */ +export const V2_DEFAULT_ROW_LIMIT = 100 +/** Hard cap on an explicit page `limit`. Larger pulls use `limit=0` (query) or the async export. */ +export const V2_MAX_ROW_LIMIT = 1000 + +/** + * 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`: `{ id, data, createdAt, updatedAt }`, + * no storage internals (`position`/`orderKey`/`executions`). `data` is keyed by + * column NAME and select cells carry their option NAME; 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()), + 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 matches every other v2 row endpoint. */ +export const v2UpsertRowDataSchema = z.object({ + row: v2ApiRowSchema, + operation: z.enum(['insert', 'update']), +}) +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. 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: v2ListTablesQuerySchema, + 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: a plain cursor page over the default row order. Filtering and + * sorting are NOT part of this surface — rich reads go through the dedicated + * `POST /query` endpoint's predicate grammar. The opaque 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 = tableRowsQueryBaseSchema + .pick({ workspaceId: true, limit: true }) + .extend({ + cursor: z.string().min(1, 'cursor must be a non-empty token').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), + }, +}) + +/** + * Rows query body. `predicate`/`sort` are the typed predicate tree / sort spec, + * field refs keyed by column NAME. `limit`: omitted → + * {@link V2_DEFAULT_ROW_LIMIT}; `0` → unbounded (whole result or a 400 + * `TABLE_QUERY_RESULT_TOO_LARGE`); `1..{@link V2_MAX_ROW_LIMIT}` → page cap. + */ +export const v2QueryRowsBodySchema = z.object({ + workspaceId: workspaceIdSchema, + predicate: predicateSchema.optional(), + sort: sortSpecSchema.optional(), + limit: z + .number({ error: 'Limit must be a number' }) + .int('Limit must be an integer') + .min(0, 'Limit must be at least 0 (use 0 for an unbounded query)') + .max( + V2_MAX_ROW_LIMIT, + `Limit cannot exceed ${V2_MAX_ROW_LIMIT}; use limit=0 for a full result or the async export for large datasets` + ) + .optional(), + cursor: z.string().min(1, 'cursor must be a non-empty token').optional(), +}) +export type V2QueryRowsBody = z.input + +/** + * Rich filtered/sorted row read with cursor pagination — the v2 read surface + * for anything beyond a plain page. POST because the predicate tree is a + * structured body, not a querystring dialect. + */ +export const v2QueryRowsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/query', + params: tableIdParamsSchema, + body: v2QueryRowsBodySchema, + 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), + ]), + }, +}) + +/** Bulk update body — v2 accepts ONLY the predicate tree as the filter. */ +export const v2UpdateRowsByPredicateBodySchema = updateRowsByFilterBodySchema.extend({ + filter: predicateSchema, +}) +export type V2UpdateRowsByPredicateBody = z.input + +export const v2UpdateRowsByFilterContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/tables/[tableId]/rows', + params: tableIdParamsSchema, + body: v2UpdateRowsByPredicateBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2UpdateRowsDataSchema), + }, +}) + +/** Bulk delete body — either row ids or a predicate-tree filter, never both. */ +export const v2DeleteTableRowsBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + filter: predicateSchema.optional(), + limit: z + .number({ error: 'Limit must be a number' }) + .int('Limit must be an integer') + .min(1, 'Limit must be at least 1') + .max( + TABLE_LIMITS.MAX_BULK_OPERATION_SIZE, + `Cannot delete more than ${TABLE_LIMITS.MAX_BULK_OPERATION_SIZE} rows per operation` + ) + .optional(), + rowIds: z + .array(z.string().min(1)) + .min(1, 'At least one row ID is required') + .max( + TABLE_LIMITS.MAX_BULK_OPERATION_SIZE, + `Cannot delete more than ${TABLE_LIMITS.MAX_BULK_OPERATION_SIZE} rows per operation` + ) + .optional(), + }) + .refine((data) => Boolean(data.filter) !== Boolean(data.rowIds), { + message: 'Provide either filter or rowIds, but not both', + }) +export type V2DeleteTableRowsBody = z.input + +export const v2DeleteTableRowsContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/[tableId]/rows', + params: tableIdParamsSchema, + body: v2DeleteTableRowsBodySchema, + 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/tables/index.ts b/apps/sim/lib/api/contracts/v2/tables/index.ts deleted file mode 100644 index 34e3902f01d..00000000000 --- a/apps/sim/lib/api/contracts/v2/tables/index.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { z } from 'zod' -import { workspaceIdSchema } from '@/lib/api/contracts/primitives' -import { - predicateSchema, - sortSpecSchema, - tableColumnSchema, - tableIdParamsSchema, -} from '@/lib/api/contracts/tables' -import { defineRouteContract } from '@/lib/api/contracts/types' - -/** - * Public v2 tables API — typed predicate grammar, cursor paging. - * - * Filters are the `{ all | any: [...] }` predicate tree (same shape the engine - * consumes); no string querystring dialect. Response bodies are fully typed. Row - * data is name-keyed and carries no storage internals (`position`/`orderKey`/ - * `executions`) — the public wire is `{ id, data, createdAt, updatedAt }`. - */ - -/** Default page size when `limit` is omitted (bounded, unlike the internal surface). */ -export const V2_DEFAULT_ROW_LIMIT = 100 -/** Hard cap on an explicit page `limit`. Larger pulls use `limit=0` or the async export. */ -export const V2_MAX_ROW_LIMIT = 1000 - -const successResponseSchema = (dataSchema: T) => - z.object({ - success: z.literal(true), - data: dataSchema, - }) - -const v2TableSummarySchema = 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(), -}) - -/** Public row shape: name-keyed data, no storage internals. */ -export const v2TableRowSchema = z.object({ - id: z.string(), - data: z.record(z.string(), z.unknown()), - createdAt: z.string(), - updatedAt: z.string(), -}) - -export const v2ListTablesQuerySchema = z.object({ - workspaceId: workspaceIdSchema, -}) - -/** - * Rows query body. `predicate`/`sort` are the typed predicate tree / sort spec. - * `limit`: omitted → {@link V2_DEFAULT_ROW_LIMIT}; `0` → unbounded (whole result - * or a 400 `TABLE_QUERY_RESULT_TOO_LARGE`); `1..{@link V2_MAX_ROW_LIMIT}` → page cap. - */ -export const v2QueryRowsBodySchema = z.object({ - workspaceId: workspaceIdSchema, - predicate: predicateSchema.optional(), - sort: sortSpecSchema.optional(), - limit: z - .number({ error: 'Limit must be a number' }) - .int('Limit must be an integer') - .min(0, 'Limit must be at least 0 (use 0 for an unbounded query)') - .max( - V2_MAX_ROW_LIMIT, - `Limit cannot exceed ${V2_MAX_ROW_LIMIT}; use limit=0 for a full result or the async export for large datasets` - ) - .optional(), - cursor: z.string().min(1, 'cursor must be a non-empty token').optional(), -}) - -export type V2ListTablesQuery = z.output -export type V2QueryRowsBody = z.input -export type V2TableRow = z.output - -export const v2ListTablesContract = defineRouteContract({ - method: 'GET', - path: '/api/v2/tables', - query: v2ListTablesQuerySchema, - response: { - mode: 'json', - schema: successResponseSchema( - z.object({ - tables: z.array(v2TableSummarySchema), - totalCount: z.number(), - }) - ), - }, -}) - -export const v2QueryRowsContract = defineRouteContract({ - method: 'POST', - path: '/api/v2/tables/[tableId]/query', - params: tableIdParamsSchema, - body: v2QueryRowsBodySchema, - response: { - mode: 'json', - schema: successResponseSchema( - z.object({ - rows: z.array(v2TableRowSchema), - rowCount: z.number(), - totalCount: z.number().nullable(), - limit: z.number(), - /** Opaque, short-lived token. Non-null when more rows remain; stop on null. */ - nextCursor: z.string().nullable(), - }) - ), - }, -}) - -export type V2ListTablesResponse = z.output<(typeof v2ListTablesContract)['response']['schema']> -export type V2QueryRowsResponse = z.output<(typeof v2QueryRowsContract)['response']['schema']> 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..119ae6ba1a0 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -0,0 +1,465 @@ +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, + v1ImportWorkflowBodySchema, + v1ImportWorkflowDataSchema, + v1ListWorkflowsQuerySchema, + v1RollbackWorkflowDataSchema, + v1WorkflowExportPayloadSchema, +} from '@/lib/api/contracts/v1/workflows' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' +import { + cancelWorkflowExecutionReasonSchema, + workflowExecutionParamsSchema, + workflowExecutionPausedDetailSchema, + workflowExecutionStatusQuerySchema, + workflowIdParamsSchema, +} from '@/lib/api/contracts/workflows' + +/** + * 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). + * + * 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. + */ + +/** + * 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(), + 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: v2ListWorkflowsQuerySchema, + 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), + }, +}) + +/** + * 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', + 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), + }, +}) + +/** + * 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), + }, +}) + +/** + * 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 + * envelope upgraded. + */ +export const v2ExportWorkflowContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[id]/export', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v1WorkflowExportPayloadSchema), + }, +}) + +export const v2ImportWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/import', + body: v1ImportWorkflowBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v1ImportWorkflowDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index 60db7b61c7c..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() @@ -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/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/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/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/collab-doc/converter.ts b/apps/sim/lib/collab-doc/converter.ts index 990e0f906c6..2a5fafab0c9 100644 --- a/apps/sim/lib/collab-doc/converter.ts +++ b/apps/sim/lib/collab-doc/converter.ts @@ -70,6 +70,8 @@ function ensureDomForTipTap(): void { // temporal dead zone ("Cannot access 'window' before initialization"). const { JSDOM } = require('jsdom') as typeof import('jsdom') const { window: jsdomWindow } = new JSDOM('') + // double-cast-allowed: assigning the jsdom shims onto the global needs an + // index-signature view of `globalThis`, whose declared type has none. const g = globalThis as unknown as Record g.window = jsdomWindow g.document = jsdomWindow.document 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/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/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/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 dd81326fefc..4cdf2f37b10 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,23 +26,13 @@ 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' @@ -51,6 +40,11 @@ import { signalTableRowsChanged, signalTableSchemaChanged } from '@/lib/table/ev 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' @@ -67,13 +61,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, @@ -335,30 +329,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 @@ -524,13 +494,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) } @@ -1698,118 +1669,39 @@ 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' } } - if (result) signalTableSchemaChanged(args.tableId) + signalTableSchemaChanged(args.tableId) 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' } @@ -1829,13 +1721,21 @@ 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' } + } signalTableSchemaChanged(args.tableId) 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/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 9d16e13ab4f..32acba766e4 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -486,6 +486,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 ' + 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..eb42af6bf65 --- /dev/null +++ b/apps/sim/lib/core/orchestration/types.ts @@ -0,0 +1,100 @@ +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' + +/** + * 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 === '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. + * + * 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/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..ef0fccd4267 --- /dev/null +++ b/apps/sim/lib/credentials/queries.ts @@ -0,0 +1,142 @@ +import { db } from '@sim/db' +import { credential, credentialMember } from '@sim/db/schema' +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' + +/** + * 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. + */ +/** + * 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, + search, + sortBy = 'createdAt', + sortOrder = 'desc', + } = 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, searchFilter(credential.displayName, search))) + .orderBy(...listOrderBy(CREDENTIAL_SORTS[sortBy], sortOrder)) + + 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/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/folders/config.ts b/apps/sim/lib/folders/config.ts index 02e803f5749..e8e3a19f06a 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 { + 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. @@ -107,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/folders/status.ts b/apps/sim/lib/folders/status.ts index ddea423bd0b..2cf6e89037b 100644 --- a/apps/sim/lib/folders/status.ts +++ b/apps/sim/lib/folders/status.ts @@ -1,4 +1,4 @@ -import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' /** * Folder mutations can fail for one reason the shared orchestration vocabulary has no word 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..7399b83aebe 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() @@ -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() }) @@ -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..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' @@ -20,6 +35,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 +47,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' } } @@ -90,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 @@ -143,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( @@ -165,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) @@ -341,7 +401,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 +425,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 +510,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 +726,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 +869,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/mcp/queries.ts b/apps/sim/lib/mcp/queries.ts new file mode 100644 index 00000000000..789a86a4454 --- /dev/null +++ b/apps/sim/lib/mcp/queries.ts @@ -0,0 +1,88 @@ +import { db } from '@sim/db' +import { mcpServers } from '@sim/db/schema' +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 + * `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. */ +/** + * 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), + 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. */ +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/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 }) 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..5cb13daf037 --- /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 type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { captureServerEvent } from '@/lib/posthog/server' +import { getSkillActorContext } from '@/lib/skills/access' +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/table/billing.ts b/apps/sim/lib/table/billing.ts index af4aa9ee168..824b102d17b 100644 --- a/apps/sim/lib/table/billing.ts +++ b/apps/sim/lib/table/billing.ts @@ -9,6 +9,7 @@ import { resolveWorkspaceBillingPayer } from '@/lib/billing/core/billing-attribu import { maybeNotifyLimit } from '@/lib/billing/core/limit-notifications' import { getPlanTypeForLimits } from '@/lib/billing/plan-helpers' import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { getBillingDisabledTableLimits, getTablePlanLimits, @@ -183,12 +184,15 @@ function cacheLimits(workspaceId: string, limits: TablePlanLimits): void { /** * Thrown by {@link assertRowCapacity} when a write would exceed the workspace's - * current plan row limit. The message includes the lowercase `row limit` token so - * `rowWriteErrorResponse` maps it to a 400 toast carrying the real reason. + * current plan row limit. Typed as a `validation` failure so the routes answer + * 400 with the real reason — the message used to have to carry a lowercase + * `row limit` token for a substring match to find it, which made the wording + * load-bearing. */ -export class TableRowLimitError extends Error { +export class TableRowLimitError extends OrchestrationError { constructor(readonly limit: number) { super( + 'validation', `This table has reached its row limit (${limit.toLocaleString('en-US')} rows) on your current plan.` ) this.name = 'TableRowLimitError' diff --git a/apps/sim/lib/table/columns/service.ts b/apps/sim/lib/table/columns/service.ts index 2ed1dd3cbda..327c87d264d 100644 --- a/apps/sim/lib/table/columns/service.ts +++ b/apps/sim/lib/table/columns/service.ts @@ -7,6 +7,12 @@ * * Use this for: workflow executor, background jobs, testing business logic. * Use API routes for: HTTP requests, frontend clients. + * + * Caller-fixable failures throw {@link OrchestrationError} carrying the class + * the layers above map to a status, so no caller has to search the message for + * a phrase. A duplicate column name is deliberately `validation` rather than + * `conflict` — both the v1 route and the orchestration have always answered 400 + * for it, and this refactor is not the place to change a published status. */ import { db } from '@sim/db' @@ -14,6 +20,7 @@ import { userTableDefinitions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { omit } from '@sim/utils/object' import { and, count, eq, sql } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { columnMatchesRef, generateColumnId, getColumnId } from '@/lib/table/column-keys' import { columnTypeById, @@ -81,30 +88,34 @@ export async function addTableColumn( return withLockedTable(tableId, async (table, trx) => { 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 2f5baf0f46c..dd41b525317 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -13,7 +13,11 @@ 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' import { resolveRestoredFolderId } from '@/lib/folders/queries' @@ -29,8 +33,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, @@ -42,10 +44,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' } } @@ -104,7 +111,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) }) @@ -202,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, @@ -232,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)) @@ -286,13 +322,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, '')}` @@ -356,7 +398,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 @@ -478,23 +525,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 @@ -510,7 +560,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})` ) } @@ -576,12 +627,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() @@ -590,30 +640,15 @@ 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, - }) + // `workspaceId` is selected for the live-list notify below, not for an audit — + // the audit moved up to `performRenameTable`. + .returning({ id: userTableDefinitions.id, workspaceId: userTableDefinitions.workspaceId }) if (result.length === 0) { - throw new Error(`Table ${tableId} not found`) + throw new OrchestrationError('not_found', `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' }, - }) - } + const { workspaceId } = result[0] logger.info(`[${requestId}] Renamed table ${tableId} to "${newName}"`) @@ -644,9 +679,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(), @@ -674,30 +708,16 @@ 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'}`) - // Live tables list: a move changes each table's folder placement in the list result. await notifyWorkspaceTablesChanged(workspaceId) + + return { name } } /** @@ -714,11 +734,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 @@ -731,36 +748,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 } } /** @@ -841,9 +834,8 @@ export async function updateTableMetadata( export async function deleteTable( tableId: string, requestId: string, - actingUserId?: string, options?: { archivedAt?: Date; skipNotify?: boolean } -): 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); @@ -885,27 +877,16 @@ 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}`) - // Live tables list: only on a genuine archive (a no-op/already-archived delete changes nothing). // Skipped under a folder cascade — deleteFolder fires one folder-level notify for the whole subtree, // so we don't spam one relay call per archived table. if (deleted?.workspaceId && !options?.skipNotify) await notifyWorkspaceTablesChanged(deleted.workspaceId) + + // 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 } } /** @@ -924,18 +905,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/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..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, @@ -19,6 +34,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 +67,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 +444,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, + }) } } @@ -762,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 */ @@ -770,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 @@ -808,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`. @@ -1072,7 +1189,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 +1239,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 +1286,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 +1372,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 +1399,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 +1412,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 +1433,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 +1441,7 @@ export async function renameWorkspaceFile( return { ...fileRecord, name: normalizedName, + updatedAt: renamedAt, } } @@ -1336,7 +1462,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 +1502,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 +1525,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 +1558,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 +1567,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/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 8fbccef1b43..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') @@ -128,6 +131,109 @@ 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. + */ +/** + * 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( + 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 }) { + 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/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/execute-service.ts b/apps/sim/lib/workflows/executor/execute-service.ts new file mode 100644 index 00000000000..d48d84ae722 --- /dev/null +++ b/apps/sim/lib/workflows/executor/execute-service.ts @@ -0,0 +1,807 @@ +import type { workflow as workflowTable } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +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' +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 { 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, + 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 { 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' +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 + /** + * `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 { + 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 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 { + 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, + mode = 'sync', + requestHeaders, + includeThinking = false, + includeToolCalls = false, + } = 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 }) + + 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) + : await loadDeployedWorkflowState(workflowId, workspaceId) + + if (abortSignal?.aborted) { + await releaseExecutionSlot(executionId) + return failure({ kind: 'aborted', message: 'Client cancelled request', statusCode: 499 }) + } + + if (workflowData) { + workflowBlocks = workflowData.blocks + 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, + }) + } + + 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, + 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, + }) + } + } + } +} + +/** + * 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 + }) +} 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, + } +} diff --git a/apps/sim/lib/workflows/operations/export-workflow.ts b/apps/sim/lib/workflows/operations/export-workflow.ts new file mode 100644 index 00000000000..d6e26c6bff4 --- /dev/null +++ b/apps/sim/lib/workflows/operations/export-workflow.ts @@ -0,0 +1,119 @@ +import type { Edge } from 'reactflow' +import type { V1WorkflowExportPayload } from '@/lib/api/contracts/v1/workflows' +import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer' +import { parseWorkflowVariables } from '@/lib/workflows/variables/parse' + +/** + * Server-only assembly of the public workflow-export payload, shared by the v1 + * and v2 export routes so both surfaces emit byte-identical envelopes. + * + * Unlike the admin export (`/api/v1/admin/workflows/[id]/export`), which emits + * the raw state for backup/restore, this runs the payload through + * `sanitizeForExport`, which nulls three classes of sub-block value: + * - `password: true` fields, unless the value is a whole `{{ENV_VAR}}` + * reference, which is preserved so the import resolves it in the target + * workspace; + * - `oauth-input` credentials; + * - **workspace-scoped bindings** — selector fields and id-keyed fields that + * point at rows that do not exist in another workspace, cleared rather than + * carried across as dangling ids. + * + * The last class means an export is **not** a byte-for-byte clone even when + * re-imported into the same workspace: those bindings come back empty and must + * be re-selected. This matches the in-app export. + * + * Workflow **variables** are emitted as stored: they are plaintext workflow + * configuration readable by anyone with workspace read (the same permission the + * export routes require); secrets belong in environment variables, which travel + * as unresolved `{{ENV_VAR}}` references. + */ + +/** The subset of the workflow record the export payload reads. */ +export interface ExportableWorkflowRecord { + id: string + name: string + description: string | null + workspaceId: string | null + folderId: string | null + variables: unknown +} + +type ExportedEdge = V1WorkflowExportPayload['state']['edges'][number] + +/** + * Projects a persisted ReactFlow edge onto the wire shape declared by the + * response contract. Field-by-field rather than a spread because ReactFlow's + * `Edge` is looser than the contract in three places: handles are `null` when + * unset (the contract and the importer both model absent as `undefined`), + * `label` is a `ReactNode`, and the marker fields accept an `EdgeMarker` + * object. Non-serializable values in those slots are dropped rather than + * emitted as `{}`. + */ +function toExportedEdge(edge: Edge): ExportedEdge { + return { + id: edge.id, + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle ?? undefined, + targetHandle: edge.targetHandle ?? undefined, + type: edge.type, + animated: edge.animated, + style: edge.style as Record | undefined, + data: edge.data, + label: typeof edge.label === 'string' ? edge.label : undefined, + labelStyle: edge.labelStyle as Record | undefined, + labelShowBg: edge.labelShowBg, + labelBgStyle: edge.labelBgStyle as Record | undefined, + labelBgPadding: edge.labelBgPadding, + labelBgBorderRadius: edge.labelBgBorderRadius, + markerStart: typeof edge.markerStart === 'string' ? edge.markerStart : undefined, + markerEnd: typeof edge.markerEnd === 'string' ? edge.markerEnd : undefined, + } +} + +/** + * Loads the workflow's normalized state, sanitizes it, and assembles the + * portable export envelope. Returns `null` when the workflow has no persisted + * normalized state (the caller renders its own 404). + */ +export async function buildWorkflowExportPayload( + workflowData: ExportableWorkflowRecord +): Promise { + const normalizedData = await loadWorkflowFromNormalizedTables(workflowData.id) + if (!normalizedData) return null + + const sanitized = sanitizeForExport({ + blocks: normalizedData.blocks, + edges: normalizedData.edges, + loops: normalizedData.loops, + parallels: normalizedData.parallels, + metadata: { + name: workflowData.name, + description: workflowData.description ?? undefined, + }, + variables: parseWorkflowVariables(workflowData.variables), + }) + + return { + version: '1.0', + exportedAt: sanitized.exportedAt, + workflow: { + id: workflowData.id, + name: workflowData.name, + description: workflowData.description, + workspaceId: workflowData.workspaceId, + folderId: workflowData.folderId, + }, + state: { + ...sanitized.state, + edges: sanitized.state.edges.map(toExportedEdge), + metadata: { + ...sanitized.state.metadata, + name: workflowData.name, + description: workflowData.description ?? undefined, + exportedAt: sanitized.exportedAt, + }, + }, + } +} diff --git a/apps/sim/lib/workflows/operations/import-workflow.ts b/apps/sim/lib/workflows/operations/import-workflow.ts new file mode 100644 index 00000000000..53dede6d709 --- /dev/null +++ b/apps/sim/lib/workflows/operations/import-workflow.ts @@ -0,0 +1,369 @@ +import { db } from '@sim/db' +import { workflow, workspace } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { + assertFolderInWorkspace, + assertFolderMutable, + FolderLockedError, + FolderNotFoundError, +} from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import { truncate } from '@sim/utils/string' +import { and, eq, isNull } from 'drizzle-orm' +import { + V1_IMPORT_DESCRIPTION_MAX_LENGTH, + V1_IMPORT_NAME_MAX_LENGTH, +} from '@/lib/api/contracts/v1/workflows' +import { workflowStateSchema } from '@/lib/api/contracts/workflows' +import { serializeZodIssues } from '@/lib/api/server' +import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' +import { performCreateWorkflow } from '@/lib/workflows/orchestration' +import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' +import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state' +import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' +import { normalizeImportedVariables } from '@/lib/workflows/variables/parse' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +const logger = createLogger('WorkflowImportOperation') + +/** + * Server-only import orchestration shared by the v1 and v2 import routes: both + * surfaces must land byte-identical data for the same payload, so the whole + * pipeline — payload parse, state validation, normalization, transactional + * persistence with rollback, custom-tool extraction — lives here and the routes + * only authenticate and render their own envelopes. + */ + +/** + * Workflow JSON is a bounded document — a few hundred blocks at the outside. + * Capping well below the platform-wide `DEFAULT_MAX_JSON_BODY_BYTES` (50 MB) + * keeps a hostile caller from buffering a large body before validation runs. + */ +export const MAX_IMPORT_BODY_BYTES = 10 * 1024 * 1024 + +const DEFAULT_IMPORTED_WORKFLOW_NAME = 'Imported Workflow' + +const TRUNCATION_SUFFIX = '...' + +export interface ImportWorkflowParams { + workspaceId: string + folderId?: string + /** Explicit name override; wins over payload metadata. */ + name?: string + /** Explicit description override; wins over payload metadata. */ + description?: string + /** Export envelope, bare state, or a JSON string of either. */ + workflow: string | Record + userId: string + requestId: string +} + +export interface ImportedWorkflow { + id: string + name: string + description: string | null + workspaceId: string + folderId: string | null + createdAt: Date + updatedAt: Date +} + +export type ImportWorkflowResult = + | { success: true; workflow: ImportedWorkflow } + | { success: false; status: number; error: string; details?: unknown } + +/** + * Caps a payload-derived string at `maxLength` *including* the ellipsis. + * `truncate` appends its suffix after slicing, so passing the limit straight + * through would yield `maxLength + 3` characters and overshoot the very bound + * this is enforcing. + */ +function capLength(value: string, maxLength: number): string { + return truncate(value, maxLength - TRUNCATION_SUFFIX.length, TRUNCATION_SUFFIX) +} + +/** + * Reads a dot-delimited path off a parsed payload and returns it only when it + * is a non-empty string, so blank metadata falls through to the next candidate. + */ +function readString(source: unknown, path: string): string | undefined { + let current: unknown = source + for (const segment of path.split('.')) { + if (!current || typeof current !== 'object') return undefined + current = (current as Record)[segment] + } + return typeof current === 'string' && current.trim() ? current.trim() : undefined +} + +/** + * Unwraps the `{ data: ... }` response envelope the export endpoint returns, so + * a caller can pipe an export response body straight into import. + * `parseWorkflowJson` already tolerates this shape when reading the graph; + * mirroring it here keeps metadata resolution from silently falling back to the + * default name for the same payload. + */ +function unwrapResponseEnvelope(payload: unknown): unknown { + if (!payload || typeof payload !== 'object') return payload + const inner = (payload as Record).data + if (!inner || typeof inner !== 'object') return payload + const candidate = inner as Record + return candidate.state || candidate.version || candidate.workflow ? candidate : payload +} + +/** + * Resolves the imported workflow's name and description, preferring explicit + * request overrides and then the payload's own metadata. Accepts every shape + * the importer takes: the export envelope (`workflow.*`, `state.metadata.*`) + * and a bare state (`metadata.*`). + * + * Candidate order deliberately matches `extractWorkflowName` — the resolver the + * in-app importer has always used — so the same payload yields the same name on + * both surfaces. The in-app version additionally falls back to the uploaded + * filename, which has no analogue here; that is the only intended difference. + * + * Payload-derived values are capped at the same bounds the contract applies to + * the explicit overrides, otherwise the declared `maxLength` would not be the + * effective one — a caller could store an unbounded name simply by embedding it + * in the payload instead of passing it as a field. + */ +function resolveImportedMetadata( + rawPayload: unknown, + overrideName?: string, + overrideDescription?: string +): { name: string; description: string } { + const payload = unwrapResponseEnvelope(rawPayload) + + const name = + overrideName || + capLength( + readString(payload, 'state.metadata.name') || + readString(payload, 'workflow.name') || + readString(payload, 'metadata.name') || + DEFAULT_IMPORTED_WORKFLOW_NAME, + V1_IMPORT_NAME_MAX_LENGTH + ) + + const description = + overrideDescription ?? + capLength( + readString(payload, 'state.metadata.description') ?? + readString(payload, 'workflow.description') ?? + readString(payload, 'metadata.description') ?? + '', + V1_IMPORT_DESCRIPTION_MAX_LENGTH + ) + + return { name, description } +} + +/** + * Creates a new workflow in the target workspace from an export payload + * produced by the export endpoints. Block, edge, loop and parallel ids are + * regenerated on import, so the same payload can be imported repeatedly and + * alongside its source workflow without collisions. + * + * The caller must have already authenticated and authorized write access to + * `workspaceId`; this performs only resource-level checks (workspace exists, + * folder ownership/lock state). + */ +export async function importWorkflowIntoWorkspace( + params: ImportWorkflowParams +): Promise { + const { workspaceId, folderId, userId, requestId } = params + + const [workspaceData] = await db + .select({ id: workspace.id }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + + if (!workspaceData) { + return { success: false, status: 404, error: 'Workspace 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 a nonexistent one (404). + */ + try { + if (folderId) { + await assertFolderInWorkspace(folderId, workspaceId) + } + await assertFolderMutable(folderId ?? null) + } catch (error) { + if (error instanceof FolderLockedError || error instanceof FolderNotFoundError) { + return { success: false, status: error.status, error: error.message } + } + throw error + } + + const rawWorkflow = params.workflow + const workflowContent = + typeof rawWorkflow === 'string' ? rawWorkflow : JSON.stringify(rawWorkflow) + + const { data: parsedState, errors } = parseWorkflowJson(workflowContent) + if (!parsedState || errors.length > 0) { + return { success: false, status: 400, error: `Invalid workflow: ${errors.join(', ')}` } + } + + /** + * Variables are normalized before validation, not after: older exports + * carry them as an array, which is a shape `workflowStateSchema` rightly + * rejects but the importer has always accepted. Normalizing first keeps + * that tolerance while still validating what actually gets persisted. + */ + const variables = normalizeImportedVariables(parsedState.variables) + + /** + * `parseWorkflowJson` only checks that blocks/edges are structurally + * present. The normalized tables are read back through + * {@link workflowStateSchema}, and the client parses that response + * strictly — so a block field with the wrong type would persist happily here + * and then throw on every subsequent load, leaving a workflow nothing can + * open. Gate on the same schema the canonical `PUT /api/workflows/[id]/state` + * path enforces. + */ + const stateValidation = workflowStateSchema.safeParse({ ...parsedState, variables }) + if (!stateValidation.success) { + const issue = stateValidation.error.issues[0] + const path = issue?.path.join('.') + return { + success: false, + status: 400, + error: `Invalid workflow state${path ? ` at ${path}` : ''}: ${issue?.message ?? 'validation failed'}`, + details: serializeZodIssues(stateValidation.error), + } + } + + /** + * Same normalization the editor's `PUT /api/workflows/[id]/state` runs, via + * the one shared implementation — the two import surfaces must land + * byte-identical data for the same payload. + */ + const { state: preparedState, warnings } = prepareWorkflowStateForPersistence(parsedState) + if (warnings.length > 0) { + logger.warn(`[${requestId}] Normalized imported workflow with warnings`, { warnings }) + } + + const workflowState: WorkflowState = { ...parsedState, ...preparedState } + + let parsedPayload: unknown = rawWorkflow + if (typeof rawWorkflow === 'string') { + try { + parsedPayload = JSON.parse(rawWorkflow) + } catch { + parsedPayload = undefined + } + } + + const { name, description } = resolveImportedMetadata( + parsedPayload, + params.name, + params.description + ) + + const created = await performCreateWorkflow({ + name, + description, + workspaceId, + folderId, + deduplicate: true, + userId, + requestId, + }) + + if (!created.success || !created.workflow) { + const status = + created.errorCode === 'conflict' ? 409 : created.errorCode === 'validation' ? 400 : 500 + return { success: false, status, error: created.error ?? 'Failed to create workflow' } + } + + const workflowId = created.workflow.id + + /** + * The graph and the variables are written in one transaction so an import + * can never half-land, and any failure deletes the shell row created above + * — a caller that receives an error must not be left with a partially + * imported workflow in their workspace. + */ + try { + await db.transaction(async (tx) => { + const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) + if (!saveResult.success) { + throw new Error(saveResult.error || 'Failed to save workflow state') + } + + if (Object.keys(variables).length > 0) { + await tx + .update(workflow) + .set({ variables, updatedAt: new Date() }) + .where(eq(workflow.id, workflowId)) + } + }) + } catch (error) { + logger.error(`[${requestId}] Failed to persist imported workflow, rolling back`, { + workflowId, + error: getErrorMessage(error, 'Unknown error'), + }) + /** + * The rollback runs under the same conditions that just failed the write, + * so it can fail too. Losing it must not turn into an unlogged orphan: + * the caller still gets a 500, but the id is recorded loudly enough to + * clean up. + */ + try { + await db.delete(workflow).where(eq(workflow.id, workflowId)) + } catch (rollbackError) { + logger.error( + `[${requestId}] Rollback failed, workflow ${workflowId} is orphaned in workspace ${workspaceId}`, + { workflowId, workspaceId, error: getErrorMessage(rollbackError, 'Unknown error') } + ) + } + return { success: false, status: 500, error: 'Failed to save workflow state' } + } + + /** + * Matches the canonical state-write path: agent blocks may carry inline + * custom-tool definitions that must exist as workspace rows to be + * resolvable at execution. Failures are logged, not fatal — the workflow + * itself imported successfully. + */ + try { + const { saved, errors: toolErrors } = await extractAndPersistCustomTools( + workflowState, + workspaceId, + userId + ) + if (saved > 0 || toolErrors.length > 0) { + logger.info(`[${requestId}] Persisted ${saved} custom tool(s) from import`, { + workflowId, + errors: toolErrors, + }) + } + } catch (error) { + logger.error(`[${requestId}] Failed to persist custom tools from import`, { + workflowId, + error: getErrorMessage(error, 'Unknown error'), + }) + } + + logger.info(`[${requestId}] Imported workflow ${workflowId} into workspace ${workspaceId}`, { + name: created.workflow.name, + blocksCount: Object.keys(workflowState.blocks).length, + }) + + return { + success: true, + workflow: { + id: workflowId, + name: created.workflow.name, + description: created.workflow.description ?? null, + workspaceId, + folderId: created.workflow.folderId ?? null, + createdAt: created.workflow.createdAt, + updatedAt: created.workflow.updatedAt, + }, + } +} 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' 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/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/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 4abfd980bcf..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,32 +23,23 @@ 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 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 { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode deletedItems?: WorkspaceFileArchiveResult } @@ -62,7 +54,7 @@ export interface PerformMoveWorkspaceFileItemsParams { export interface PerformMoveWorkspaceFileItemsResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode movedItems?: { files: number; folders: number } } @@ -76,7 +68,7 @@ export interface PerformRenameWorkspaceFileParams { export interface PerformRenameWorkspaceFileResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode file?: WorkspaceFileRecord } @@ -89,7 +81,7 @@ export interface PerformRestoreWorkspaceFileParams { export interface PerformRestoreWorkspaceFileResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode } export interface PerformCreateWorkspaceFileFolderParams { @@ -102,7 +94,7 @@ export interface PerformCreateWorkspaceFileFolderParams { export interface PerformCreateWorkspaceFileFolderResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode folder?: WorkspaceFileFolderRecord } @@ -118,7 +110,7 @@ export interface PerformUpdateWorkspaceFileFolderParams { export interface PerformUpdateWorkspaceFileFolderResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode folder?: WorkspaceFileFolderRecord } @@ -131,7 +123,7 @@ export interface PerformRestoreWorkspaceFileFolderParams { export interface PerformRestoreWorkspaceFileFolderResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode folder?: WorkspaceFileFolderRecord restoredItems?: WorkspaceFileArchiveResult } @@ -139,7 +131,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 { @@ -174,6 +166,7 @@ export async function performDeleteWorkspaceFileItems( resourceType: AuditResourceType.FILE, description: `Deleted ${fileIds.length} file${fileIds.length === 1 ? '' : 's'}`, metadata: { fileIds }, + request, }) } @@ -192,6 +185,7 @@ export async function performDeleteWorkspaceFileItems( folders: deletedItems.folders, }, }, + request, }) } @@ -199,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' } } } @@ -277,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' } } } @@ -308,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' } } } @@ -323,7 +329,7 @@ export interface PerformMoveRenameWorkspaceFileParams { export interface PerformMoveRenameWorkspaceFileResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode file?: WorkspaceFileRecord } @@ -406,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' } } } @@ -440,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' } } } @@ -487,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' } } } @@ -528,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/apps/sim/next.config.ts b/apps/sim/next.config.ts index 097fc99960d..4ed5a9551e3 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -320,6 +320,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 COOP, 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. */ diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 887bbc21416..5517154e889 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -1273,6 +1273,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( diff --git a/package.json b/package.json index e6baf996389..08a18794f5a 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/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 } diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 7c744ed7c6b..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: 1003, - zodRoutes: 1003, + totalRoutes: 1048, + zodRoutes: 1048, nonZodRoutes: 0, } as const @@ -24,7 +24,7 @@ const BOUNDARY_POLICY_BASELINE = { clientHookLocalSchemaConstructors: 0, clientHookRawFetches: 0, clientSameOriginApiFetches: 0, - doubleCasts: 9, + doubleCasts: 8, rawJsonReads: 5, untypedResponses: 0, annotationsMissingReason: 0, diff --git a/scripts/check-openapi-specs.ts b/scripts/check-openapi-specs.ts new file mode 100644 index 00000000000..5fa5ad826a5 --- /dev/null +++ b/scripts/check-openapi-specs.ts @@ -0,0 +1,511 @@ +#!/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', + 'openapi-v2-resources.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 +} + +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}`) + } + } +} + +/** 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}` + + const docBodySchema = ((deref(op.requestBody, spec) as Json)?.content as Json)?.[ + 'application/json' + ] as Json | undefined + if (contract.body && docBodySchema?.schema) { + const zodRoot = toJsonSchema(contract.body, 'input') + if (zodRoot) { + diffSchemaFields( + zodRoot, + zodRoot, + docBodySchema.schema, + spec, + { specFile, label, name, where: 'body' }, + '', + 0 + ) + } + } + + 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 zodRoot = toJsonSchema(contract.response.schema, 'output') + if (zodRoot) { + diffSchemaFields( + zodRoot, + zodRoot, + docSchema, + spec, + { specFile, label, name, where: 'response' }, + '', + 0 + ) + } + } + } +} + +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.` +)