diff --git a/apps/docs/content/docs/de/api-reference/getting-started.mdx b/apps/docs/content/docs/de/api-reference/getting-started.mdx index 25c8cfdbf2e..7e94ab0d7bd 100644 --- a/apps/docs/content/docs/de/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/de/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/de/api-reference/meta.json b/apps/docs/content/docs/de/api-reference/meta.json index d8a1fb142c6..74cedc72725 100644 --- a/apps/docs/content/docs/de/api-reference/meta.json +++ b/apps/docs/content/docs/de/api-reference/meta.json @@ -2,6 +2,7 @@ "title": "API Reference", "root": true, "pages": [ + "---Getting Started---", "getting-started", "authentication", "---SDKs---", @@ -10,9 +11,13 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", "(generated)/audit-logs", "(generated)/tables", - "(generated)/files" + "(generated)/files", + "(generated)/knowledge-bases", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json b/apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json new file mode 100644 index 00000000000..52458d430c3 --- /dev/null +++ b/apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json @@ -0,0 +1,3 @@ +{ + "pages": ["executeWorkflow", "getWorkflowExecution", "cancelExecution", "getJobStatus"] +} diff --git a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json index 491129e3cdf..6fb5dc0f8bf 100644 --- a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json +++ b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json @@ -1,13 +1,9 @@ { "pages": [ - "executeWorkflow", - "getWorkflowExecution", - "cancelExecution", "listWorkflows", "getWorkflow", "deployWorkflow", "undeployWorkflow", - "rollbackWorkflow", - "getJobStatus" + "rollbackWorkflow" ] } diff --git a/apps/docs/content/docs/en/api-reference/getting-started.mdx b/apps/docs/content/docs/en/api-reference/getting-started.mdx index 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/en/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/en/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/en/api-reference/meta.json b/apps/docs/content/docs/en/api-reference/meta.json index c99ab8eb13f..74cedc72725 100644 --- a/apps/docs/content/docs/en/api-reference/meta.json +++ b/apps/docs/content/docs/en/api-reference/meta.json @@ -10,12 +10,14 @@ "typescript", "---Endpoints---", "(generated)/workflows", - "(generated)/human-in-the-loop", "(generated)/logs", - "(generated)/usage", "(generated)/audit-logs", "(generated)/tables", "(generated)/files", - "(generated)/knowledge-bases" + "(generated)/knowledge-bases", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/content/docs/en/platform/enterprise/audit-logs.mdx b/apps/docs/content/docs/en/platform/enterprise/audit-logs.mdx index 9bcf9dfb0ed..b9d039c2a56 100644 --- a/apps/docs/content/docs/en/platform/enterprise/audit-logs.mdx +++ b/apps/docs/content/docs/en/platform/enterprise/audit-logs.mdx @@ -33,7 +33,7 @@ Audit logs are also accessible through the Sim API for integration with external ```http GET /api/v1/audit-logs -Authorization: Bearer +X-API-Key: ``` **Query parameters:** @@ -71,11 +71,18 @@ Authorization: Bearer "createdAt": "2026-04-20T21:16:00.000Z" } ], - "nextCursor": "eyJpZCI6ImFiYzEyMyJ9" + "nextCursor": "eyJpZCI6ImFiYzEyMyJ9", + "limits": { + "workflowExecutionRateLimit": { + "sync": { "requestsPerMinute": 60, "maxBurst": 10, "remaining": 59, "resetAt": "2026-04-20T21:17:00.000Z" }, + "async": { "requestsPerMinute": 30, "maxBurst": 5, "remaining": 30, "resetAt": "2026-04-20T21:17:00.000Z" } + }, + "usage": { "currentPeriodCost": 1.25, "limit": 50, "plan": "enterprise", "isExceeded": false } + } } ``` -Paginate by passing the `nextCursor` value as the `cursor` parameter in the next request. When `nextCursor` is absent, you have reached the last page. +Paginate by passing the `nextCursor` value as the `cursor` parameter in the next request. When `nextCursor` is absent, you have reached the last page. Each entry also includes `actorName`; `metadata` is an arbitrary per-action JSON object. The `limits` object reports your current rate-limit and usage status. The API accepts both personal and workspace-scoped API keys. Rate limits apply — the response includes `X-RateLimit-*` headers with your current limit and remaining quota. diff --git a/apps/docs/content/docs/es/api-reference/getting-started.mdx b/apps/docs/content/docs/es/api-reference/getting-started.mdx index 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/es/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/es/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/es/api-reference/meta.json b/apps/docs/content/docs/es/api-reference/meta.json index c96dc5d2edc..74cedc72725 100644 --- a/apps/docs/content/docs/es/api-reference/meta.json +++ b/apps/docs/content/docs/es/api-reference/meta.json @@ -2,6 +2,7 @@ "title": "API Reference", "root": true, "pages": [ + "---Getting Started---", "getting-started", "authentication", "---SDKs---", @@ -10,7 +11,13 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/content/docs/fr/api-reference/getting-started.mdx b/apps/docs/content/docs/fr/api-reference/getting-started.mdx index 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/fr/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/fr/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/fr/api-reference/meta.json b/apps/docs/content/docs/fr/api-reference/meta.json index c96dc5d2edc..74cedc72725 100644 --- a/apps/docs/content/docs/fr/api-reference/meta.json +++ b/apps/docs/content/docs/fr/api-reference/meta.json @@ -2,6 +2,7 @@ "title": "API Reference", "root": true, "pages": [ + "---Getting Started---", "getting-started", "authentication", "---SDKs---", @@ -10,7 +11,13 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/content/docs/ja/api-reference/getting-started.mdx b/apps/docs/content/docs/ja/api-reference/getting-started.mdx index 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/ja/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/ja/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/ja/api-reference/meta.json b/apps/docs/content/docs/ja/api-reference/meta.json index c96dc5d2edc..74cedc72725 100644 --- a/apps/docs/content/docs/ja/api-reference/meta.json +++ b/apps/docs/content/docs/ja/api-reference/meta.json @@ -2,6 +2,7 @@ "title": "API Reference", "root": true, "pages": [ + "---Getting Started---", "getting-started", "authentication", "---SDKs---", @@ -10,7 +11,13 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/content/docs/zh/api-reference/getting-started.mdx b/apps/docs/content/docs/zh/api-reference/getting-started.mdx index 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/zh/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/zh/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/zh/api-reference/meta.json b/apps/docs/content/docs/zh/api-reference/meta.json index c96dc5d2edc..74cedc72725 100644 --- a/apps/docs/content/docs/zh/api-reference/meta.json +++ b/apps/docs/content/docs/zh/api-reference/meta.json @@ -2,6 +2,7 @@ "title": "API Reference", "root": true, "pages": [ + "---Getting Started---", "getting-started", "authentication", "---SDKs---", @@ -10,7 +11,13 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/lib/openapi.ts b/apps/docs/lib/openapi.ts index af5f7a2b4c8..41f0687139a 100644 --- a/apps/docs/lib/openapi.ts +++ b/apps/docs/lib/openapi.ts @@ -2,8 +2,17 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { createOpenAPI } from 'fumadocs-openapi/server' +const SPEC_FILES = [ + 'openapi-core.json', + 'openapi-v2-logs.json', + 'openapi-v2-workflows.json', + 'openapi-v2-tables.json', + 'openapi-v2-knowledge.json', + 'openapi-v2-files-audit.json', +] as const + export const openapi = createOpenAPI({ - input: ['./openapi.json'], + input: SPEC_FILES.map((file) => `./${file}`), }) interface OpenAPIOperation { @@ -24,20 +33,34 @@ function resolveRef(ref: string, spec: Record): unknown { return current } -function resolveRefs(obj: unknown, spec: Record, depth = 0): unknown { - if (depth > 10) return obj +function resolveRefs( + obj: unknown, + spec: Record, + seen: Set = new Set(), + depth = 0 +): unknown { + // Generous backstop against pathological fan-out; real schemas nest far shallower. + if (depth > 50) return obj if (Array.isArray(obj)) { - return obj.map((item) => resolveRefs(item, spec, depth + 1)) + return obj.map((item) => resolveRefs(item, spec, seen, depth + 1)) } if (obj && typeof obj === 'object') { const record = obj as Record - if ('$ref' in record && typeof record.$ref === 'string') { - const resolved = resolveRef(record.$ref, spec) - return resolveRefs(resolved, spec, depth + 1) + if (typeof record.$ref === 'string') { + const ref = record.$ref + // Break reference cycles: if this $ref is already being expanded above us, + // leave it untouched instead of recursing forever. + if (seen.has(ref)) return record + const resolved = resolveRef(ref, spec) + if (resolved === undefined) return record + seen.add(ref) + const out = resolveRefs(resolved, spec, seen, depth + 1) + seen.delete(ref) + return out } const result: Record = {} for (const [key, value] of Object.entries(record)) { - result[key] = resolveRefs(value, spec, depth + 1) + result[key] = resolveRefs(value, spec, seen, depth + 1) } return result } @@ -48,14 +71,34 @@ function formatSchema(schema: unknown): string { return JSON.stringify(schema, null, 2) } -let cachedSpec: Record | null = null +let cachedSpecs: Record[] | null = null + +function getSpecs(): Record[] { + if (!cachedSpecs) { + cachedSpecs = SPEC_FILES.map( + (file) => + JSON.parse(readFileSync(join(process.cwd(), file), 'utf8')) as Record + ) + } + return cachedSpecs +} -function getSpec(): Record { - if (!cachedSpec) { - const specPath = join(process.cwd(), 'openapi.json') - cachedSpec = JSON.parse(readFileSync(specPath, 'utf8')) as Record +/** + * Locate an operation by path + method across every rendered spec, returning the + * operation together with the spec that owns it so `$ref`s resolve within the + * correct document (each spec carries its own `components`). + */ +function findOperation( + path: string, + method: string +): { operation: Record; spec: Record } | undefined { + const key = method.toLowerCase() + for (const spec of getSpecs()) { + const pathObj = (spec.paths as Record> | undefined)?.[path] + const operation = pathObj?.[key] as Record | undefined + if (operation) return { operation, spec } } - return cachedSpec + return undefined } export function getApiSpecContent( @@ -63,22 +106,19 @@ export function getApiSpecContent( description: string | undefined, operations: OpenAPIOperation[] ): string { - const spec = getSpec() - if (!operations || operations.length === 0) { return `# ${title}\n\n${description || ''}` } const op = operations[0] const method = op.method.toUpperCase() - const pathObj = (spec.paths as Record>)?.[op.path] - const operation = pathObj?.[op.method.toLowerCase()] as Record | undefined + const found = findOperation(op.path, op.method) - if (!operation) { + if (!found) { return `# ${title}\n\n${description || ''}` } - const resolved = resolveRefs(operation, spec) as Record + const resolved = resolveRefs(found.operation, found.spec) as Record const lines: string[] = [] lines.push(`# ${title}`) diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json new file mode 100644 index 00000000000..53a99c2e866 --- /dev/null +++ b/apps/docs/openapi-core.json @@ -0,0 +1,2948 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API — Execution & Usage", + "description": "Run workflows, poll and cancel executions, resume Human-in-the-Loop pauses, and check usage limits.", + "version": "1.0.0", + "contact": { + "name": "Sim Support", + "email": "help@sim.ai", + "url": "https://www.sim.ai" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Execution", + "description": "Run workflows, poll execution status, and cancel runs" + }, + { + "name": "Human in the Loop", + "description": "Manage paused workflow executions and resume them with input" + }, + { + "name": "Usage", + "description": "Check rate limits and billing usage" + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/workflows/{id}/execute": { + "post": { + "operationId": "executeWorkflow", + "summary": "Execute Workflow", + "description": "Execute a deployed workflow. Supports synchronous, asynchronous, and streaming modes. For async execution, the response includes a statusUrl you can poll for results.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/workflows/{id}/execute\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"input\": {\n \"key\": \"value\"\n }\n }'" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the deployed workflow to execute.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + } + ], + "requestBody": { + "description": "Execution configuration including input values and execution mode options.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "input": { + "type": "object", + "description": "Key-value pairs matching the workflow's defined input fields. Use the Get Workflow endpoint to discover available input fields.", + "additionalProperties": true + }, + "triggerType": { + "type": "string", + "description": "How this execution was triggered. Defaults to api when called via the REST API. Recorded in execution logs for filtering." + }, + "stream": { + "type": "boolean", + "description": "When true, returns results as Server-Sent Events (SSE) for real-time block-by-block output streaming." + }, + "selectedOutputs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of specific block IDs whose outputs to include in the response. When omitted, all block outputs are returned." + } + } + }, + "example": { + "input": { + "query": "What is the weather in Tokyo?" + } + } + } + } + }, + "responses": { + "200": { + "description": "Synchronous execution completed successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecutionResult" + }, + "example": { + "success": true, + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", + "output": { + "content": "The weather in Tokyo is sunny, 22°C." + }, + "error": null, + "metadata": { + "startTime": "2026-01-15T10:30:00Z", + "endTime": "2026-01-15T10:30:01Z", + "duration": 1250 + } + } + } + } + }, + "202": { + "description": "Asynchronous execution has been queued. Poll the statusUrl for results.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AsyncExecutionResult" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + } + } + }, + "/api/workflows/{id}/executions/{executionId}": { + "get": { + "operationId": "getWorkflowExecution", + "summary": "Get Execution Status", + "description": "Get the current status of a workflow execution. Returns the run's lifecycle state (`running`, `paused`, `completed`, `failed`, etc.), timing, error, and optionally per-block outputs. Designed for polling — works for any execution, including ones that pause and resume.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/workflows/{id}/executions/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + }, + { + "id": "curl-with-outputs", + "label": "cURL (with block outputs)", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/workflows/{id}/executions/{executionId}?selectedOutputs=blockId,blockId.field&includeOutput=true\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The unique identifier of the execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + }, + { + "name": "includeOutput", + "in": "query", + "required": false, + "description": "When `true` and the execution has `status: completed`, include the workflow's final output in the response.", + "schema": { + "type": "string", + "enum": ["true", "false"] + } + }, + { + "name": "selectedOutputs", + "in": "query", + "required": false, + "description": "Comma-separated block-output selectors. A bare `blockId` returns that block's full output; a dot-path like `blockId.field` or `blockId.nested.path` returns just that value. Results are returned in the `blockOutputs` map keyed by the selector string.", + "schema": { + "type": "string", + "example": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf,c1b90bce-8a82-42a5-b6a5-5762846c2eaf.waitDuration" + } + } + ], + "responses": { + "200": { + "description": "Execution status returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowExecutionStatus" + }, + "examples": { + "completed": { + "summary": "Completed run", + "value": { + "executionId": "9254f1c9-5a11-4a12-91e3-8065293f3609", + "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "status": "completed", + "trigger": "api", + "level": "info", + "startedAt": "2026-05-15T19:43:12.189Z", + "endedAt": "2026-05-15T19:45:45.224Z", + "totalDurationMs": 153035, + "paused": null, + "cost": { + "total": 0.005 + }, + "error": null, + "finalOutput": null, + "blockOutputs": null + } + }, + "paused": { + "summary": "Currently paused run", + "value": { + "executionId": "772749f6-ee81-414c-a2c3-671549dd62b8", + "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "status": "paused", + "trigger": "manual", + "level": "info", + "startedAt": "2026-05-15T22:25:57.178Z", + "endedAt": "2026-05-15T22:25:57.215Z", + "totalDurationMs": 1, + "paused": { + "pausedAt": "2026-05-15T22:25:57.216Z", + "resumeAt": "2026-05-16T18:25:57.200Z", + "pauseKind": "time", + "blockedOnBlockId": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf", + "pausedExecutionId": "438bf05b-bd3c-4011-b78e-b19c112eeb66", + "pausePointCount": 1, + "resumedCount": 0 + }, + "cost": { + "total": 0.005 + }, + "error": null, + "finalOutput": null, + "blockOutputs": null + } + }, + "failed": { + "summary": "Failed run", + "value": { + "executionId": "3ccfdeed-a63c-4e86-98e2-8bec723bca52", + "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "status": "failed", + "trigger": "api", + "level": "error", + "startedAt": "2026-05-15T22:24:50.991Z", + "endedAt": "2026-05-15T22:24:50.999Z", + "totalDurationMs": 2, + "paused": null, + "cost": { + "total": 0.005 + }, + "error": "Wait 1: Wait time exceeds maximum of 5 minutes; enable async mode to wait up to 30 days", + "finalOutput": null, + "blockOutputs": null + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/workflows/{id}/executions/{executionId}/cancel": { + "post": { + "operationId": "cancelExecution", + "summary": "Cancel Execution", + "description": "Cancel a running workflow execution. Only effective for executions that are still in progress.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/workflows/{id}/executions/{executionId}/cancel\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The unique identifier of the execution to cancel.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + } + ], + "responses": { + "200": { + "description": "Execution was successfully cancelled.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the cancellation was successful." + }, + "executionId": { + "type": "string", + "description": "The ID of the cancelled execution." + } + } + }, + "example": { + "success": true, + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/jobs/{jobId}": { + "get": { + "operationId": "getJobStatus", + "summary": "Get Job Status", + "description": "Poll the status of an asynchronous workflow execution. Use the jobId returned from the Execute Workflow endpoint when the execution is queued asynchronously.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/jobs/{jobId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "description": "The job identifier returned in the async execution response.", + "schema": { + "type": "string", + "example": "job_4a3b2c1d0e" + } + } + ], + "responses": { + "200": { + "description": "Current status of the job. When completed, includes the execution output.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobStatus" + }, + "example": { + "success": true, + "taskId": "job_abc123", + "status": "completed", + "output": { + "content": "Done" + }, + "metadata": { + "startTime": "2026-01-15T10:30:00Z" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/workflows/{id}/paused": { + "get": { + "operationId": "listPausedExecutions", + "summary": "List Paused Executions", + "description": "List all paused executions for a workflow. Workflows pause at Human in the Loop blocks and wait for input before continuing. Use this endpoint to discover which executions need attention.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/workflows/{id}/paused?status=paused\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "description": "Filter paused executions by status.", + "schema": { + "type": "string", + "example": "paused" + } + } + ], + "responses": { + "200": { + "description": "List of paused executions.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pausedExecutions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PausedExecutionSummary" + } + } + } + }, + "example": { + "pausedExecutions": [ + { + "id": "pe_abc123", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "status": "paused", + "totalPauseCount": 1, + "resumedCount": 0, + "pausedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "expiresAt": null, + "metadata": null, + "triggerIds": [], + "pausePoints": [ + { + "contextId": "ctx_xyz789", + "blockId": "block_hitl_1", + "registeredAt": "2026-01-15T10:30:00Z", + "resumeStatus": "paused", + "snapshotReady": true, + "resumeLinks": { + "apiUrl": "https://www.sim.ai/api/resume/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13/ctx_xyz789", + "uiUrl": "https://www.sim.ai/resume/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "contextId": "ctx_xyz789", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "response": { + "displayData": { + "title": "Approval Required", + "message": "Please review this request" + }, + "formFields": [] + } + } + ] + } + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/workflows/{id}/paused/{executionId}": { + "get": { + "operationId": "getPausedExecution", + "summary": "Get Paused Execution", + "description": "Get detailed information about a specific paused execution, including its pause points, execution snapshot, and resume queue. Use this to inspect the state before resuming.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/workflows/{id}/paused/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + } + ], + "responses": { + "200": { + "description": "Paused execution details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PausedExecutionDetail" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/resume/{workflowId}/{executionId}": { + "get": { + "operationId": "getPausedExecutionByResumePath", + "summary": "Get Paused Execution (Resume Path)", + "description": "Get detailed information about a specific paused execution using the resume URL path. Returns the same data as the workflow paused execution detail endpoint.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + } + ], + "responses": { + "200": { + "description": "Paused execution details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PausedExecutionDetail" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + } + } + } + }, + "/api/resume/{workflowId}/{executionId}/{contextId}": { + "get": { + "operationId": "getPauseContext", + "summary": "Get Pause Context", + "description": "Get detailed information about a specific pause context within a paused execution. Returns the pause point details, resume queue state, and any active resume entry.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}/{contextId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + }, + { + "name": "contextId", + "in": "path", + "required": true, + "description": "The pause context ID to retrieve details for.", + "schema": { + "type": "string", + "example": "ctx_xyz789" + } + } + ], + "responses": { + "200": { + "description": "Pause context details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PauseContextDetail" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "post": { + "operationId": "resumeExecution", + "summary": "Resume Execution", + "description": "Resume a paused workflow execution by providing input for a specific pause context. The execution continues from where it paused, using the provided input. Supports synchronous, asynchronous, and streaming modes (determined by the original execution's configuration).", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}/{contextId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"input\": {\n \"approved\": true,\n \"comment\": \"Looks good to me\"\n }\n }'" + } + ], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + }, + { + "name": "contextId", + "in": "path", + "required": true, + "description": "The pause context ID to resume. Found in the pause point's contextId field or resumeLinks.", + "schema": { + "type": "string", + "example": "ctx_xyz789" + } + } + ], + "requestBody": { + "description": "Input data for the resumed execution. The structure depends on the workflow's Human in the Loop block configuration.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "input": { + "type": "object", + "description": "Key-value pairs to pass as input to the resumed execution. If omitted, the entire request body is used as input.", + "additionalProperties": true + } + } + }, + "example": { + "input": { + "approved": true, + "comment": "Looks good to me" + } + } + } + } + }, + "responses": { + "200": { + "description": "Resume execution completed synchronously, or resume was queued behind another in-progress resume.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResumeResult" + }, + { + "type": "object", + "description": "Resume has been queued behind another in-progress resume.", + "properties": { + "status": { + "type": "string", + "enum": ["queued"], + "description": "Indicates the resume is queued." + }, + "executionId": { + "type": "string", + "description": "The execution ID assigned to this resume." + }, + "queuePosition": { + "type": "integer", + "description": "Position in the resume queue." + }, + "message": { + "type": "string", + "description": "Human-readable status message." + } + } + }, + { + "type": "object", + "description": "Resume execution started (non-API-key callers). The execution runs asynchronously.", + "properties": { + "status": { + "type": "string", + "enum": ["started"], + "description": "Indicates the resume execution has started." + }, + "executionId": { + "type": "string", + "description": "The execution ID for the resumed workflow." + }, + "message": { + "type": "string", + "description": "Human-readable status message." + } + } + } + ] + }, + "examples": { + "sync": { + "summary": "Synchronous completion", + "value": { + "success": true, + "status": "completed", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "output": { + "result": "Approved and processed" + }, + "error": null, + "metadata": { + "duration": 850, + "startTime": "2026-01-15T10:35:00Z", + "endTime": "2026-01-15T10:35:01Z" + } + } + }, + "queued": { + "summary": "Queued behind another resume", + "value": { + "status": "queued", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "queuePosition": 2, + "message": "Resume queued. It will run after current resumes finish." + } + }, + "started": { + "summary": "Execution started (fire and forget)", + "value": { + "status": "started", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "message": "Resume execution started." + } + } + } + } + } + }, + "202": { + "description": "Resume execution has been queued for asynchronous processing. Poll the statusUrl for results.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AsyncExecutionResult" + }, + "example": { + "success": true, + "async": true, + "jobId": "job_4a3b2c1d0e", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "message": "Resume execution queued", + "statusUrl": "https://www.sim.ai/api/jobs/job_4a3b2c1d0e" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "503": { + "description": "Failed to queue the resume execution. Retry the request.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error message." + } + } + } + } + } + } + } + } + }, + "/api/users/me/usage-limits": { + "get": { + "operationId": "getUsageLimits", + "summary": "Get Usage Limits", + "description": "Retrieve your current rate limits, usage spending, and storage consumption for the billing period.", + "tags": ["Usage"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/users/me/usage-limits\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "responses": { + "200": { + "description": "Current rate limits, usage, and storage information.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsageLimits" + }, + "example": { + "success": true, + "rateLimit": { + "sync": { + "limit": 100, + "remaining": 95, + "reset": "2026-01-15T11:00:00Z" + }, + "async": { + "limit": 50, + "remaining": 48, + "reset": "2026-01-15T11:00:00Z" + } + }, + "usage": { + "currentPeriodCost": 12.5, + "limit": 100, + "plan": "pro" + }, + "storage": { + "usedBytes": 5242880, + "limitBytes": 1073741824, + "percentUsed": 0.49 + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "parameters": [] + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "TableId": { + "name": "tableId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "description": "The unique identifier of the table." + }, + "RowId": { + "name": "rowId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "description": "The unique identifier of the row." + }, + "WorkspaceId": { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "The unique identifier of the workspace." + } + }, + "schemas": { + "ColumnDefinition": { + "type": "object", + "description": "Definition of a table column including its type and constraints.", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "description": "Column name. Must start with a letter or underscore.", + "example": "email", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$" + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "date", "json"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "description": "Whether the column requires a value on insert.", + "default": false + }, + "unique": { + "type": "boolean", + "description": "Whether values in this column must be unique across all rows.", + "default": false + } + } + }, + "Table": { + "type": "object", + "description": "A user-defined table with a typed schema.", + "properties": { + "id": { + "type": "string", + "description": "Unique table identifier.", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "name": { + "type": "string", + "description": "Table name.", + "example": "contacts" + }, + "description": { + "type": "string", + "description": "Optional description of the table.", + "example": "Customer contact records" + }, + "schema": { + "type": "object", + "description": "Table schema definition.", + "properties": { + "columns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ColumnDefinition" + }, + "description": "Array of column definitions for the table." + } + } + }, + "rowCount": { + "type": "integer", + "description": "Current number of rows in the table." + }, + "maxRows": { + "type": "integer", + "description": "Maximum rows allowed by the current billing plan." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the table was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the table was last modified." + } + } + }, + "TableRow": { + "type": "object", + "description": "A single row in a table.", + "properties": { + "id": { + "type": "string", + "description": "Unique row identifier.", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "data": { + "type": "object", + "additionalProperties": true, + "description": "Row data as key-value pairs matching the table schema." + }, + "position": { + "type": "integer", + "description": "Row's position/order in the table." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the row was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the row was last modified." + } + } + }, + "WorkflowSummary": { + "type": "object", + "description": "Summary representation of a workflow returned in list operations.", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Human-readable workflow name.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Optional description of what the workflow does.", + "example": "Routes incoming support tickets and drafts responses" + }, + "folderId": { + "type": "string", + "nullable": true, + "description": "The folder this workflow belongs to. null if at the workspace root.", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + }, + "workspaceId": { + "type": "string", + "description": "The workspace this workflow belongs to.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow is currently deployed and available for API execution.", + "example": true + }, + "deployedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the most recent deployment. null if never deployed.", + "example": "2025-06-15T10:30:00Z" + }, + "runCount": { + "type": "integer", + "description": "Total number of times this workflow has been executed.", + "example": 142 + }, + "lastRunAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the most recent execution. null if never run.", + "example": "2025-06-20T14:15:22Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was created.", + "example": "2025-01-10T09:00:00Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was last modified.", + "example": "2025-06-18T16:45:00Z" + } + } + }, + "WorkflowDetail": { + "type": "object", + "description": "Full workflow representation including input field definitions and configuration.", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Human-readable workflow name.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Optional description of what the workflow does.", + "example": "Routes incoming support tickets and drafts responses" + }, + "folderId": { + "type": "string", + "nullable": true, + "description": "The folder this workflow belongs to. null if at the workspace root.", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + }, + "workspaceId": { + "type": "string", + "description": "The workspace this workflow belongs to.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow is currently deployed and available for API execution.", + "example": true + }, + "deployedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the most recent deployment. null if never deployed.", + "example": "2025-06-15T10:30:00Z" + }, + "runCount": { + "type": "integer", + "description": "Total number of times this workflow has been executed.", + "example": 142 + }, + "lastRunAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the most recent execution. null if never run.", + "example": "2025-06-20T14:15:22Z" + }, + "variables": { + "type": "object", + "description": "Workflow-level variables and their current values.", + "example": {} + }, + "inputs": { + "type": "object", + "description": "The workflow's input field definitions. Use these to construct the input object when executing the workflow.", + "properties": { + "fields": { + "type": "object", + "description": "Map of field names to their type definitions and configuration.", + "additionalProperties": true, + "example": {} + } + } + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was created.", + "example": "2025-01-10T09:00:00Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was last modified.", + "example": "2025-06-18T16:45:00Z" + } + } + }, + "WorkflowDeployment": { + "type": "object", + "description": "Deployment state of a workflow after a deploy, undeploy, or rollback operation.", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow is deployed and available for API execution after the operation.", + "example": true + }, + "deployedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the active deployment. null after an undeploy.", + "example": "2026-06-12T10:30:00Z" + }, + "version": { + "type": "integer", + "description": "The deployment version that is now active. Omitted for undeploy.", + "example": 4 + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-fatal warnings. Present when trigger, schedule, or MCP side-effect sync is still in progress or needs a redeploy." + } + } + }, + "ExecutionResult": { + "type": "object", + "description": "Result of a synchronous workflow execution.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the workflow executed successfully without errors.", + "example": true + }, + "executionId": { + "type": "string", + "description": "Unique identifier for this execution. Use this to query logs or cancel the execution.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "output": { + "type": "object", + "description": "Workflow output keyed by block name and output field. Structure depends on the workflow's block configuration.", + "additionalProperties": true, + "example": { + "result": "Hello, world!" + } + }, + "error": { + "type": "string", + "nullable": true, + "description": "Error message if the execution failed. null on success.", + "example": null + }, + "metadata": { + "type": "object", + "description": "Execution timing metadata.", + "properties": { + "duration": { + "type": "integer", + "description": "Total execution duration in milliseconds.", + "example": 1250 + }, + "startTime": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2025-06-20T14:15:22Z" + }, + "endTime": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed.", + "example": "2025-06-20T14:15:23Z" + } + } + } + } + }, + "AsyncExecutionResult": { + "type": "object", + "description": "Response returned when a workflow execution is queued for asynchronous processing.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the execution was successfully queued.", + "example": true + }, + "async": { + "type": "boolean", + "description": "Always true for async executions. Use this to distinguish from synchronous responses.", + "example": true + }, + "jobId": { + "type": "string", + "description": "Internal job queue identifier for tracking the execution.", + "example": "job_4a3b2c1d0e" + }, + "executionId": { + "type": "string", + "description": "Unique execution identifier. Use this to query execution status or cancel.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "message": { + "type": "string", + "description": "Human-readable status message (e.g., \"Execution queued\").", + "example": "Execution queued" + }, + "statusUrl": { + "type": "string", + "format": "uri", + "description": "URL to poll for execution status and results. Returns the full execution result once complete.", + "example": "https://www.sim.ai/api/jobs/job_4a3b2c1d0e" + } + } + }, + "LogEntry": { + "type": "object", + "description": "Summary of a single workflow execution log entry.", + "properties": { + "id": { + "type": "string", + "description": "Unique log entry identifier.", + "example": "log_7x8y9z0a1b" + }, + "workflowId": { + "type": "string", + "description": "The workflow that was executed.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "executionId": { + "type": "string", + "description": "Unique execution identifier for this run.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "level": { + "type": "string", + "description": "Log severity. info for successful executions, error for failures.", + "example": "info" + }, + "trigger": { + "type": "string", + "description": "How the execution was triggered (e.g., api, manual, webhook, schedule, chat).", + "example": "api" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2025-06-20T14:15:22Z" + }, + "endedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed.", + "example": "2025-06-20T14:15:23Z" + }, + "totalDurationMs": { + "type": "integer", + "description": "Total execution duration in milliseconds.", + "example": 1250 + }, + "cost": { + "type": "object", + "description": "Cost summary for this execution.", + "properties": { + "total": { + "type": "number", + "description": "Total cost of this execution in USD.", + "example": 0.0032 + } + } + }, + "files": { + "type": "object", + "nullable": true, + "description": "File outputs produced during execution. null if no files were generated.", + "example": null + } + } + }, + "LogDetail": { + "type": "object", + "description": "Detailed log entry with full execution data, workflow metadata, and cost breakdown.", + "properties": { + "id": { + "type": "string", + "description": "Unique log entry identifier.", + "example": "log_7x8y9z0a1b" + }, + "workflowId": { + "type": "string", + "description": "The workflow that was executed.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "executionId": { + "type": "string", + "description": "Unique execution identifier for this run.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "level": { + "type": "string", + "description": "Log severity. info for successful executions, error for failures.", + "example": "info" + }, + "trigger": { + "type": "string", + "description": "How the execution was triggered (e.g., api, manual, webhook, schedule, chat).", + "example": "api" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2025-06-20T14:15:22Z" + }, + "endedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed.", + "example": "2025-06-20T14:15:23Z" + }, + "totalDurationMs": { + "type": "integer", + "description": "Total execution duration in milliseconds.", + "example": 1250 + }, + "workflow": { + "type": "object", + "description": "Summary metadata about the workflow at the time of execution.", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Workflow name at the time of execution.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Workflow description at the time of execution.", + "example": "Routes incoming support tickets and drafts responses" + } + } + }, + "executionData": { + "type": "object", + "description": "Detailed execution data including block-level traces and final output.", + "properties": { + "traceSpans": { + "type": "array", + "description": "Block-level execution traces with timing, inputs, and outputs for each block that ran.", + "items": { + "type": "object" + } + }, + "finalOutput": { + "type": "object", + "description": "The workflow's final output after all blocks completed." + } + } + }, + "cost": { + "type": "object", + "description": "Detailed cost breakdown for this execution.", + "properties": { + "total": { + "type": "number", + "description": "Total cost of this execution in USD.", + "example": 0.0032 + }, + "tokens": { + "type": "object", + "description": "Aggregate token usage across all AI model calls in this execution.", + "properties": { + "prompt": { + "type": "integer", + "description": "Total prompt (input) tokens consumed.", + "example": 450 + }, + "completion": { + "type": "integer", + "description": "Total completion (output) tokens generated.", + "example": 120 + }, + "total": { + "type": "integer", + "description": "Total tokens (prompt + completion).", + "example": 570 + } + } + }, + "models": { + "type": "object", + "description": "Per-model cost and token breakdown. Keys are model identifiers (e.g., gpt-4o, claude-sonnet-4-20250514).", + "additionalProperties": { + "type": "object", + "description": "Cost and token details for a specific model.", + "properties": { + "input": { + "type": "number", + "description": "Cost of prompt tokens for this model in USD." + }, + "output": { + "type": "number", + "description": "Cost of completion tokens for this model in USD." + }, + "total": { + "type": "number", + "description": "Total cost for this model in USD." + }, + "tokens": { + "type": "object", + "description": "Token usage for this specific model.", + "properties": { + "prompt": { + "type": "integer", + "description": "Prompt tokens consumed by this model." + }, + "completion": { + "type": "integer", + "description": "Completion tokens generated by this model." + }, + "total": { + "type": "integer", + "description": "Total tokens for this model." + } + } + } + } + } + } + } + } + } + }, + "Limits": { + "type": "object", + "description": "Rate limit and usage information included in every API response.", + "properties": { + "workflowExecutionRateLimit": { + "type": "object", + "description": "Current rate limit status for workflow executions.", + "properties": { + "sync": { + "description": "Rate limit bucket for synchronous executions.", + "$ref": "#/components/schemas/RateLimitBucket" + }, + "async": { + "description": "Rate limit bucket for asynchronous executions.", + "$ref": "#/components/schemas/RateLimitBucket" + } + } + }, + "usage": { + "type": "object", + "description": "Current billing period usage and plan limits.", + "properties": { + "currentPeriodCost": { + "type": "number", + "description": "Total spend in the current billing period in USD.", + "example": 1.25 + }, + "limit": { + "type": "number", + "description": "Maximum allowed spend for the current billing period in USD.", + "example": 50 + }, + "plan": { + "type": "string", + "description": "Your current subscription plan (e.g., free, pro, team).", + "example": "pro" + }, + "isExceeded": { + "type": "boolean", + "description": "Whether the usage limit has been exceeded. Executions may be blocked when true.", + "example": false + } + } + } + } + }, + "RateLimitBucket": { + "type": "object", + "description": "Rate limit status for a specific execution type.", + "properties": { + "requestsPerMinute": { + "type": "integer", + "description": "Maximum number of requests allowed per minute.", + "example": 60 + }, + "maxBurst": { + "type": "integer", + "description": "Maximum number of concurrent requests allowed in a burst.", + "example": 10 + }, + "remaining": { + "type": "integer", + "description": "Number of requests remaining in the current rate limit window.", + "example": 59 + }, + "resetAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the rate limit window resets.", + "example": "2025-06-20T14:16:00Z" + } + } + }, + "JobStatus": { + "type": "object", + "description": "Status of an asynchronous job.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the request was successful.", + "example": true + }, + "taskId": { + "type": "string", + "description": "The unique identifier of the job.", + "example": "job_4a3b2c1d0e" + }, + "status": { + "type": "string", + "enum": ["queued", "processing", "completed", "failed"], + "description": "Current status of the job.", + "example": "completed" + }, + "metadata": { + "type": "object", + "description": "Timing metadata for the job.", + "properties": { + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the job started processing.", + "example": "2025-06-20T14:15:22Z" + }, + "completedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the job completed. Present only when status is completed or failed.", + "example": "2025-06-20T14:15:23Z" + }, + "duration": { + "type": "integer", + "description": "Duration of the job in milliseconds. Present only when status is completed or failed.", + "example": 1250 + } + } + }, + "output": { + "description": "The workflow execution output. Present only when status is completed.", + "type": "object", + "example": { + "result": "Hello, world!" + } + }, + "error": { + "description": "Error details. Present only when status is failed.", + "type": "string", + "example": null + }, + "estimatedDuration": { + "type": "integer", + "description": "Estimated duration in milliseconds. Present only when status is queued or processing.", + "example": 2000 + } + } + }, + "WorkflowExecutionStatus": { + "type": "object", + "description": "Current status of a workflow execution.", + "properties": { + "executionId": { + "type": "string", + "description": "The unique identifier of the execution.", + "example": "9254f1c9-5a11-4a12-91e3-8065293f3609" + }, + "workflowId": { + "type": "string", + "description": "The unique identifier of the workflow.", + "example": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7" + }, + "status": { + "type": "string", + "enum": ["pending", "running", "paused", "completed", "failed", "cancelled"], + "description": "Current normalized lifecycle status. `paused` is set when a row exists in pausedExecutions with status `paused` or `partially_resumed`; otherwise the workflowExecutionLogs row's status field is used.", + "example": "completed" + }, + "trigger": { + "type": "string", + "enum": ["api", "manual", "schedule", "webhook", "chat"], + "description": "What triggered the execution.", + "example": "api" + }, + "level": { + "type": "string", + "enum": ["info", "warning", "error"], + "description": "Log level of the execution.", + "example": "info" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2026-05-15T19:43:12.189Z" + }, + "endedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp when execution ended. Null while the run is in flight.", + "example": "2026-05-15T19:45:45.224Z" + }, + "totalDurationMs": { + "type": "integer", + "nullable": true, + "description": "Total duration of the execution in milliseconds. Null while the run is in flight.", + "example": 153035 + }, + "paused": { + "type": "object", + "nullable": true, + "description": "Pause-state details. Present only when status is `paused`.", + "properties": { + "pausedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was paused.", + "example": "2026-05-15T22:25:57.216Z" + }, + "resumeAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Earliest scheduled resume time across active pause points. Null for human-only pauses.", + "example": "2026-05-16T18:25:57.200Z" + }, + "pauseKind": { + "type": "string", + "enum": ["time", "human"], + "nullable": true, + "description": "What kind of pause the workflow is waiting on.", + "example": "time" + }, + "blockedOnBlockId": { + "type": "string", + "nullable": true, + "description": "The block currently blocking resume.", + "example": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf" + }, + "pausedExecutionId": { + "type": "string", + "description": "ID of the paused-execution row, useful for cross-referencing with the human-in-the-loop endpoints.", + "example": "438bf05b-bd3c-4011-b78e-b19c112eeb66" + }, + "pausePointCount": { + "type": "integer", + "description": "Total number of pause points recorded for this execution.", + "example": 1 + }, + "resumedCount": { + "type": "integer", + "description": "Number of pause points already resumed.", + "example": 0 + } + } + }, + "cost": { + "type": "object", + "nullable": true, + "description": "Cost summary. Detailed token / model breakdown lives on the /v1/logs detail endpoint.", + "properties": { + "total": { + "type": "number", + "description": "Total cost in USD.", + "example": 0.005 + } + } + }, + "error": { + "type": "string", + "nullable": true, + "description": "Error message. Present only when status is `failed`.", + "example": null + }, + "finalOutput": { + "type": "object", + "nullable": true, + "description": "The workflow's final output. Returned only when ?includeOutput=true AND status is `completed`.", + "example": null + }, + "blockOutputs": { + "type": "object", + "nullable": true, + "description": "Per-block outputs keyed by the selector string. Returned only when `?selectedOutputs` is set.", + "additionalProperties": true, + "example": { + "c1b90bce-8a82-42a5-b6a5-5762846c2eaf.waitDuration": 60000, + "c1b90bce-8a82-42a5-b6a5-5762846c2eaf.status": "completed" + } + } + } + }, + "AuditLogEntry": { + "type": "object", + "description": "An enterprise audit log entry recording an action taken in the workspace.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the audit log entry.", + "example": "audit_2c3d4e5f6g" + }, + "workspaceId": { + "type": "string", + "nullable": true, + "description": "The workspace where the action occurred.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "actorId": { + "type": "string", + "nullable": true, + "description": "The user ID of the person who performed the action.", + "example": "user_abc123" + }, + "actorName": { + "type": "string", + "nullable": true, + "description": "Display name of the person who performed the action.", + "example": "Jane Smith" + }, + "actorEmail": { + "type": "string", + "nullable": true, + "description": "Email address of the person who performed the action.", + "example": "jane@example.com" + }, + "action": { + "type": "string", + "description": "The action that was performed (e.g., workflow.created, member.invited).", + "example": "workflow.deployed" + }, + "resourceType": { + "type": "string", + "description": "The type of resource affected (e.g., workflow, workspace, member).", + "example": "workflow" + }, + "resourceId": { + "type": "string", + "nullable": true, + "description": "The unique identifier of the affected resource.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "resourceName": { + "type": "string", + "nullable": true, + "description": "Display name of the affected resource.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Human-readable description of the action.", + "example": "Deployed workflow Customer Support Agent" + }, + "metadata": { + "type": "object", + "nullable": true, + "description": "Additional context about the action.", + "example": null + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the action occurred.", + "example": "2025-06-20T14:15:22Z" + } + } + }, + "UsageLimits": { + "type": "object", + "description": "Current rate limits, usage, and storage information for the authenticated user.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the request was successful." + }, + "rateLimit": { + "type": "object", + "description": "Rate limit status for workflow executions.", + "properties": { + "sync": { + "description": "Rate limit bucket for synchronous executions.", + "allOf": [ + { + "$ref": "#/components/schemas/RateLimitBucket" + }, + { + "type": "object", + "properties": { + "isLimited": { + "type": "boolean", + "description": "Whether the rate limit has been reached." + } + } + } + ] + }, + "async": { + "description": "Rate limit bucket for asynchronous executions.", + "allOf": [ + { + "$ref": "#/components/schemas/RateLimitBucket" + }, + { + "type": "object", + "properties": { + "isLimited": { + "type": "boolean", + "description": "Whether the rate limit has been reached." + } + } + } + ] + }, + "authType": { + "type": "string", + "description": "The authentication type used (api or manual)." + } + } + }, + "usage": { + "type": "object", + "description": "Current billing period usage.", + "properties": { + "currentPeriodCost": { + "type": "number", + "description": "Total spend in the current billing period in USD." + }, + "limit": { + "type": "number", + "description": "Maximum allowed spend for the current billing period in USD." + }, + "plan": { + "type": "string", + "description": "Your current subscription plan (e.g., free, pro, team)." + } + } + }, + "storage": { + "type": "object", + "description": "File storage usage.", + "properties": { + "usedBytes": { + "type": "integer", + "description": "Total storage used in bytes." + }, + "limitBytes": { + "type": "integer", + "description": "Maximum storage allowed in bytes." + }, + "percentUsed": { + "type": "number", + "description": "Percentage of storage used (0-100)." + } + } + } + } + }, + "FileMetadata": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique file identifier.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "name": { + "type": "string", + "description": "Original filename.", + "example": "data.csv" + }, + "size": { + "type": "integer", + "description": "File size in bytes.", + "example": 1024 + }, + "type": { + "type": "string", + "description": "MIME type of the file.", + "example": "text/csv" + }, + "key": { + "type": "string", + "description": "Storage key for the file.", + "example": "workspace/abc-123/1709571234-xyz-data.csv" + }, + "uploadedBy": { + "type": "string", + "description": "User ID of the uploader." + }, + "uploadedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of when the file was uploaded." + } + } + }, + "KnowledgeBase": { + "type": "object", + "description": "A knowledge base for storing and searching document embeddings.", + "properties": { + "id": { + "type": "string", + "description": "Unique knowledge base identifier." + }, + "name": { + "type": "string", + "description": "Knowledge base name." + }, + "description": { + "type": "string", + "nullable": true, + "description": "Optional description." + }, + "tokenCount": { + "type": "integer", + "description": "Total token count across all documents." + }, + "embeddingModel": { + "type": "string", + "description": "Embedding model used (e.g. text-embedding-3-small)." + }, + "embeddingDimension": { + "type": "integer", + "description": "Embedding vector dimension." + }, + "chunkingConfig": { + "$ref": "#/components/schemas/ChunkingConfig" + }, + "docCount": { + "type": "integer", + "description": "Number of documents in the knowledge base." + }, + "connectorTypes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Types of connectors attached to this knowledge base." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the knowledge base was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the knowledge base was last modified." + } + } + }, + "ChunkingConfig": { + "type": "object", + "description": "Configuration for how documents are split into chunks for embedding.", + "properties": { + "maxSize": { + "type": "integer", + "minimum": 100, + "maximum": 4000, + "default": 1024, + "description": "Maximum chunk size in tokens." + }, + "minSize": { + "type": "integer", + "minimum": 1, + "maximum": 2000, + "default": 100, + "description": "Minimum chunk size in characters." + }, + "overlap": { + "type": "integer", + "minimum": 0, + "maximum": 500, + "default": 200, + "description": "Overlap between chunks in tokens." + } + } + }, + "KnowledgeDocument": { + "type": "object", + "description": "A document in a knowledge base.", + "properties": { + "id": { + "type": "string", + "description": "Unique document identifier." + }, + "knowledgeBaseId": { + "type": "string", + "description": "Knowledge base this document belongs to." + }, + "filename": { + "type": "string", + "description": "Original filename." + }, + "fileSize": { + "type": "integer", + "description": "File size in bytes." + }, + "mimeType": { + "type": "string", + "description": "MIME type of the file." + }, + "processingStatus": { + "type": "string", + "enum": ["pending", "processing", "completed", "failed"], + "description": "Current processing status." + }, + "chunkCount": { + "type": "integer", + "description": "Number of chunks created from this document." + }, + "tokenCount": { + "type": "integer", + "description": "Total token count." + }, + "characterCount": { + "type": "integer", + "description": "Total character count." + }, + "enabled": { + "type": "boolean", + "description": "Whether the document is enabled for search." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the document was uploaded." + } + } + }, + "KnowledgeDocumentDetail": { + "type": "object", + "description": "Detailed document information including processing and connector details.", + "properties": { + "id": { + "type": "string", + "description": "Unique document identifier." + }, + "knowledgeBaseId": { + "type": "string", + "description": "Knowledge base this document belongs to." + }, + "filename": { + "type": "string", + "description": "Original filename." + }, + "fileSize": { + "type": "integer", + "description": "File size in bytes." + }, + "mimeType": { + "type": "string", + "description": "MIME type of the file." + }, + "processingStatus": { + "type": "string", + "enum": ["pending", "processing", "completed", "failed"], + "description": "Current processing status." + }, + "processingError": { + "type": "string", + "nullable": true, + "description": "Error message if processing failed." + }, + "processingStartedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When processing started." + }, + "processingCompletedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When processing completed." + }, + "chunkCount": { + "type": "integer", + "description": "Number of chunks created." + }, + "tokenCount": { + "type": "integer", + "description": "Total token count." + }, + "characterCount": { + "type": "integer", + "description": "Total character count." + }, + "enabled": { + "type": "boolean", + "description": "Whether the document is enabled for search." + }, + "connectorId": { + "type": "string", + "nullable": true, + "description": "Connector ID if sourced from an external connector." + }, + "connectorType": { + "type": "string", + "nullable": true, + "description": "Connector type (e.g. google-drive, notion)." + }, + "sourceUrl": { + "type": "string", + "nullable": true, + "description": "Original source URL for connector-sourced documents." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the document was uploaded." + } + } + }, + "SearchResult": { + "type": "object", + "description": "A single search result from knowledge base vector search.", + "properties": { + "documentId": { + "type": "string", + "description": "ID of the source document." + }, + "documentName": { + "type": "string", + "description": "Filename of the source document." + }, + "sourceUrl": { + "type": "string", + "nullable": true, + "description": "URL to the original source document for connector-synced documents (e.g., a Confluence page, Google Doc, or Notion page). Null for documents without an external source." + }, + "content": { + "type": "string", + "description": "The matched chunk content." + }, + "chunkIndex": { + "type": "integer", + "description": "Index of the chunk within the document." + }, + "metadata": { + "type": "object", + "description": "Tag metadata associated with the chunk (display names mapped to values)." + }, + "similarity": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Similarity score (0-1, where 1 is most similar)." + } + } + }, + "TagFilter": { + "type": "object", + "description": "A tag-based filter for knowledge base search.", + "required": ["tagName", "value"], + "properties": { + "tagName": { + "type": "string", + "description": "Display name of the tag to filter by." + }, + "fieldType": { + "type": "string", + "enum": ["text", "number", "date", "boolean"], + "default": "text", + "description": "Data type of the tag field." + }, + "operator": { + "type": "string", + "default": "eq", + "description": "Comparison operator (e.g. eq, neq, gt, lt, gte, lte, contains, between)." + }, + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "description": "Value to filter by." + }, + "valueTo": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ], + "description": "Upper bound value for 'between' operator." + } + } + }, + "PausedExecutionSummary": { + "type": "object", + "description": "Summary of a paused workflow execution.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the paused execution record." + }, + "workflowId": { + "type": "string", + "description": "The workflow this execution belongs to." + }, + "executionId": { + "type": "string", + "description": "The execution that was paused." + }, + "status": { + "type": "string", + "description": "Current status of the paused execution.", + "example": "paused" + }, + "totalPauseCount": { + "type": "integer", + "description": "Total number of pause points in this execution." + }, + "resumedCount": { + "type": "integer", + "description": "Number of pause points that have been resumed." + }, + "pausedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the execution was paused." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the paused execution record was last updated." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the paused execution will expire and be cleaned up." + }, + "metadata": { + "type": "object", + "nullable": true, + "description": "Additional metadata associated with the paused execution.", + "additionalProperties": true + }, + "triggerIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of triggers that initiated the original execution." + }, + "pausePoints": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PausePoint" + }, + "description": "List of pause points in the execution." + } + } + }, + "PausePoint": { + "type": "object", + "description": "A point in the workflow where execution has been paused awaiting human input.", + "properties": { + "contextId": { + "type": "string", + "description": "Unique identifier for this pause context. Used when resuming execution." + }, + "blockId": { + "type": "string", + "description": "The block ID where execution paused." + }, + "response": { + "description": "Data returned by the block before pausing, including display data and form fields." + }, + "registeredAt": { + "type": "string", + "format": "date-time", + "description": "When this pause point was registered." + }, + "resumeStatus": { + "type": "string", + "enum": ["paused", "resumed", "failed", "queued", "resuming"], + "description": "Current status of this pause point." + }, + "snapshotReady": { + "type": "boolean", + "description": "Whether the execution snapshot is ready for resumption." + }, + "resumeLinks": { + "type": "object", + "description": "Links for resuming this pause point.", + "properties": { + "apiUrl": { + "type": "string", + "format": "uri", + "description": "API endpoint URL to POST resume input to." + }, + "uiUrl": { + "type": "string", + "format": "uri", + "description": "UI URL for a human to review and approve." + }, + "contextId": { + "type": "string", + "description": "The context ID for this pause point." + }, + "executionId": { + "type": "string", + "description": "The execution ID." + }, + "workflowId": { + "type": "string", + "description": "The workflow ID." + } + } + }, + "queuePosition": { + "type": "integer", + "nullable": true, + "description": "Position in the resume queue, if queued." + }, + "latestResumeEntry": { + "$ref": "#/components/schemas/ResumeQueueEntry", + "nullable": true, + "description": "The most recent resume queue entry for this pause point." + }, + "parallelScope": { + "type": "object", + "description": "Scope information when the pause occurs inside a parallel branch.", + "properties": { + "parallelId": { + "type": "string", + "description": "Identifier of the parallel execution group." + }, + "branchIndex": { + "type": "integer", + "description": "Index of the branch within the parallel group." + }, + "branchTotal": { + "type": "integer", + "description": "Total number of branches in the parallel group." + } + } + }, + "loopScope": { + "type": "object", + "description": "Scope information when the pause occurs inside a loop.", + "properties": { + "loopId": { + "type": "string", + "description": "Identifier of the loop." + }, + "iteration": { + "type": "integer", + "description": "Current loop iteration number." + } + } + } + } + }, + "ResumeQueueEntry": { + "type": "object", + "description": "An entry in the resume execution queue.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for this queue entry." + }, + "pausedExecutionId": { + "type": "string", + "description": "The paused execution this entry belongs to." + }, + "parentExecutionId": { + "type": "string", + "description": "The original execution that was paused." + }, + "newExecutionId": { + "type": "string", + "description": "The new execution ID created for the resume." + }, + "contextId": { + "type": "string", + "description": "The pause context ID being resumed." + }, + "resumeInput": { + "description": "The input provided when resuming." + }, + "status": { + "type": "string", + "description": "Status of this queue entry (e.g., pending, claimed, completed, failed)." + }, + "queuedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the entry was added to the queue." + }, + "claimedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When execution started processing this entry." + }, + "completedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When execution completed." + }, + "failureReason": { + "type": "string", + "nullable": true, + "description": "Reason for failure, if the resume failed." + } + } + }, + "PausedExecutionDetail": { + "type": "object", + "description": "Detailed information about a paused execution, including the execution snapshot and resume queue.", + "allOf": [ + { + "$ref": "#/components/schemas/PausedExecutionSummary" + }, + { + "type": "object", + "properties": { + "executionSnapshot": { + "type": "object", + "description": "Serialized execution state for resumption.", + "properties": { + "snapshot": { + "type": "string", + "description": "Serialized execution snapshot data." + }, + "triggerIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Trigger IDs from the snapshot." + } + } + }, + "queue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResumeQueueEntry" + }, + "description": "Resume queue entries for this execution." + } + } + } + ] + }, + "PauseContextDetail": { + "type": "object", + "description": "Detailed information about a specific pause context within a paused execution.", + "properties": { + "execution": { + "$ref": "#/components/schemas/PausedExecutionSummary", + "description": "Summary of the parent paused execution." + }, + "pausePoint": { + "$ref": "#/components/schemas/PausePoint", + "description": "The specific pause point for this context." + }, + "queue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResumeQueueEntry" + }, + "description": "Resume queue entries for this context." + }, + "activeResumeEntry": { + "$ref": "#/components/schemas/ResumeQueueEntry", + "nullable": true, + "description": "The currently active resume entry, if any." + } + } + }, + "ResumeResult": { + "type": "object", + "description": "Result of a synchronous resume execution.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the resume execution completed successfully." + }, + "status": { + "type": "string", + "description": "Execution status.", + "enum": ["completed", "failed", "paused", "cancelled"], + "example": "completed" + }, + "executionId": { + "type": "string", + "description": "The new execution ID for the resumed workflow." + }, + "output": { + "type": "object", + "description": "Workflow output from the resumed execution.", + "additionalProperties": true + }, + "error": { + "type": "string", + "nullable": true, + "description": "Error message if the execution failed." + }, + "metadata": { + "type": "object", + "description": "Execution timing metadata.", + "properties": { + "duration": { + "type": "integer", + "description": "Total execution duration in milliseconds." + }, + "startTime": { + "type": "string", + "format": "date-time", + "description": "When the resume execution started." + }, + "endTime": { + "type": "string", + "format": "date-time", + "description": "When the resume execution completed." + } + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Invalid request parameters. Check the details array for specific validation errors.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message describing the validation failure." + }, + "details": { + "type": "array", + "description": "List of specific validation errors with field-level details.", + "items": { + "type": "object" + } + } + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "Forbidden": { + "description": "Access denied. You do not have permission to access this resource. For audit log endpoints, this requires an Enterprise subscription and organization admin/owner role.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found. Verify the ID is correct and belongs to your workspace.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration specified in the Retry-After header before retrying.", + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message with rate limit details." + } + } + } + } + } + }, + "RowsUpdated": { + "description": "Rows updated.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Indicates whether the request was successful." + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation message describing how many rows were updated." + }, + "updatedCount": { + "type": "integer", + "description": "Number of rows that were updated." + }, + "updatedRowIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of IDs for each row that was updated." + } + }, + "description": "Response payload." + } + } + }, + "example": { + "success": true, + "data": { + "message": "Rows updated successfully", + "updatedCount": 2, + "updatedRowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ] + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json new file mode 100644 index 00000000000..402866bc262 --- /dev/null +++ b/apps/docs/openapi-v2-files-audit.json @@ -0,0 +1,1125 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 — Files & Audit Logs", + "description": "Version 2 of the Sim REST API for the Files and Audit Logs surfaces.\n\n## Conventions (v2)\n\nEvery v2 endpoint shares one response family:\n\n- **Single resource:** `{ \"data\": T }`\n- **List:** `{ \"data\": T[], \"nextCursor\": string | null }`\n- **Error:** `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`\n\n### Cursor pagination\n\nLists use an opaque keyset cursor (Stripe/Slack-style): pass `limit` and `cursor` in, receive `data` and `nextCursor` out. Treat `cursor` as opaque — pass back the `nextCursor` from the previous page verbatim. When `nextCursor` is `null` there are no more results. Total counts are not returned on lists.\n\n### Rate limiting\n\nRate-limit state is carried in response headers, not the body: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (an ISO 8601 timestamp). A throttled request returns `429` with a `Retry-After` header (seconds).\n\n### Authentication\n\nAll endpoints authenticate with the `X-API-Key` header (a personal or workspace API key). Files endpoints are workspace-scoped via the required `workspaceId` query parameter. Audit Logs endpoints are organization-scoped enterprise endpoints and require an Enterprise subscription plus an organization admin or owner role.", + "version": "2.0.0", + "contact": { + "name": "Sim Support", + "email": "help@sim.ai", + "url": "https://www.sim.ai" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Files", + "description": "Upload, download, list, and archive workspace files (v2). Workspace-scoped via the required workspaceId query parameter." + }, + { + "name": "Audit Logs", + "description": "Query the organization audit trail (v2). Organization-scoped enterprise endpoints requiring an Enterprise subscription and an organization admin or owner role." + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/v2/files": { + "get": { + "operationId": "listFiles", + "summary": "List Files", + "description": "List the active files in a workspace with opaque cursor pagination. Results are ordered by upload time. Pass the `nextCursor` from a previous response to fetch the next page; a `null` `nextCursor` means there are no more results.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/files?workspaceId=YOUR_WORKSPACE_ID&limit=100\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of files to return per page. Clamped to the range 1–1000. Defaults to 100.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 100 + } + }, + { + "$ref": "#/components/parameters/Cursor" + } + ], + "responses": { + "200": { + "description": "A page of workspace files.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileListResponse" + }, + "example": { + "data": [ + { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "name": "data.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", + "uploadedBy": "user_abc123", + "uploadedAt": "2026-01-15T10:30:00Z" + } + ], + "nextCursor": "eyJ1cGxvYWRlZEF0IjoiMjAyNi0wMS0xNVQxMDozMDowMFoiLCJpZCI6IndmX1YxU3RHWFI4ejVqZEhpNkJteVQ5MSJ9" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "uploadFile", + "summary": "Upload File", + "description": "Upload a file to a workspace as `multipart/form-data` with a single `file` field. The workspace is supplied as the `workspaceId` query parameter (not a form field) so authorization runs before the request body is buffered. Maximum file size is 100MB. Duplicate filenames within a workspace are rejected. Returns `201 Created`.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/files?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -F \"file=@/path/to/file.csv\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "requestBody": { + "required": true, + "description": "The file to upload, sent as multipart/form-data.", + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": ["file"], + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "The file to upload. Maximum size is 100MB." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The file was uploaded successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileResponse" + }, + "example": { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "name": "data.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", + "uploadedBy": "user_abc123", + "uploadedAt": "2026-01-15T10:30:00Z" + } + } + } + } + }, + "400": { + "description": "The request was malformed: an invalid `workspaceId` query parameter, a body that is not valid multipart form data, or a missing `file` form field.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "file form field is required" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "description": "A file with the same name already exists in this workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "A file with this name already exists in the workspace" + } + } + } + } + }, + "413": { + "description": "The upload exceeds the 100MB file size limit, or the workspace storage limit would be exceeded.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "File size exceeds 100MB limit (142.30MB)" + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/{fileId}": { + "get": { + "operationId": "downloadFile", + "summary": "Download File", + "description": "Download the raw bytes of a file. The success response body is the file content itself — there is no JSON envelope. The actual `Content-Type` reflects the stored file's MIME type (shown here as `application/octet-stream`); `Content-Disposition` and `Content-Length` describe the attachment, and rate-limit state is returned in the `X-RateLimit-*` headers. Lookups are workspace-scoped: a file that belongs to another workspace returns `404`. Error responses still use the canonical v2 JSON error envelope.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/files/{fileId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -o downloaded-file.csv" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The raw file content as binary data. The `Content-Type` header reflects the file's stored MIME type.", + "headers": { + "Content-Type": { + "description": "MIME type of the file. Varies per file; defaults to application/octet-stream when unknown.", + "schema": { + "type": "string", + "example": "text/csv" + } + }, + "Content-Disposition": { + "description": "Attachment disposition carrying the (sanitized and RFC 5987 encoded) filename.", + "schema": { + "type": "string", + "example": "attachment; filename=\"data.csv\"; filename*=UTF-8''data.csv" + } + }, + "Content-Length": { + "description": "Size of the file in bytes.", + "schema": { + "type": "string", + "example": "1024" + } + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteFile", + "summary": "Delete File", + "description": "Archive (soft delete) a file in a workspace. The operation is workspace-scoped and records its own audit entry. Returns the file id and a `deleted` acknowledgement.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/files/{fileId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The file was archived.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2DeleteFileResponse" + }, + "example": { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "deleted": true + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "description": "The file could not be archived because of a conflicting state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "Failed to delete file" + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/audit-logs": { + "get": { + "operationId": "listAuditLogs", + "summary": "List Audit Logs", + "description": "List audit log entries for the authenticated user's organization with opaque cursor pagination. These are organization-scoped (not workspace-scoped) enterprise endpoints: the caller must belong to an organization with an active Enterprise subscription and hold an admin or owner role — otherwise the request returns `403`. The `ipAddress` and `userAgent` fields are intentionally excluded from entries for privacy.", + "tags": ["Audit Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/audit-logs?limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "action", + "in": "query", + "required": false, + "description": "Filter by action type (e.g., file.uploaded, workflow.deployed, member.invited).", + "schema": { + "type": "string" + } + }, + { + "name": "resourceType", + "in": "query", + "required": false, + "description": "Filter by resource type (e.g., file, workflow, workspace, member).", + "schema": { + "type": "string" + } + }, + { + "name": "resourceId", + "in": "query", + "required": false, + "description": "Filter by a specific resource ID.", + "schema": { + "type": "string" + } + }, + { + "name": "workspaceId", + "in": "query", + "required": false, + "description": "Filter by a workspace within your organization. Must belong to your organization, otherwise the request returns 400.", + "schema": { + "type": "string" + } + }, + { + "name": "actorId", + "in": "query", + "required": false, + "description": "Filter by the user who performed the action. Must be a member of your organization, otherwise the request returns 400.", + "schema": { + "type": "string" + } + }, + { + "name": "startDate", + "in": "query", + "required": false, + "description": "Only return entries at or after this ISO 8601 timestamp.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "endDate", + "in": "query", + "required": false, + "description": "Only return entries at or before this ISO 8601 timestamp.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "includeDeparted", + "in": "query", + "required": false, + "description": "When true, include entries from users who have left the organization. Defaults to false.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of entries to return per page. Must be between 1 and 100. Defaults to 50.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "$ref": "#/components/parameters/Cursor" + } + ], + "responses": { + "200": { + "description": "A page of audit log entries.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2AuditLogListResponse" + }, + "example": { + "data": [ + { + "id": "audit_2c3d4e5f6g", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "actorId": "user_abc123", + "actorName": "Jane Smith", + "actorEmail": "jane@example.com", + "action": "file.uploaded", + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "resourceName": "data.csv", + "description": "Uploaded file \"data.csv\" via API", + "metadata": { + "fileSize": 1024, + "fileType": "text/csv" + }, + "createdAt": "2026-01-15T10:30:00Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "description": "The request was malformed: an invalid query parameter, an `actorId` that is not a member of your organization, or a `workspaceId` that does not belong to your organization.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "actorId is not a member of your organization" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/audit-logs/{id}": { + "get": { + "operationId": "getAuditLog", + "summary": "Get Audit Log", + "description": "Retrieve a single audit log entry by ID, scoped to the authenticated user's organization. Organization-scoped (not workspace-scoped): the caller must belong to an organization with an active Enterprise subscription and hold an admin or owner role — otherwise the request returns `403`. An entry outside your organization returns `404` (existence is not leaked). The `ipAddress` and `userAgent` fields are intentionally excluded for privacy.", + "tags": ["Audit Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/audit-logs/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique audit log entry identifier.", + "schema": { + "type": "string", + "minLength": 1, + "example": "audit_2c3d4e5f6g" + } + } + ], + "responses": { + "200": { + "description": "The audit log entry.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2AuditLogResponse" + }, + "example": { + "data": { + "id": "audit_2c3d4e5f6g", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "actorId": "user_abc123", + "actorName": "Jane Smith", + "actorEmail": "jane@example.com", + "action": "file.uploaded", + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "resourceName": "data.csv", + "description": "Uploaded file \"data.csv\" via API", + "metadata": { + "fileSize": 1024, + "fileType": "text/csv" + }, + "createdAt": "2026-01-15T10:30:00Z" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "WorkspaceIdQuery": { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "The unique identifier of the workspace.", + "schema": { + "type": "string", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + }, + "FileIdPath": { + "name": "fileId", + "in": "path", + "required": true, + "description": "The unique identifier of the file.", + "schema": { + "type": "string", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + } + }, + "Cursor": { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor. Pass the `nextCursor` value from a previous response to fetch the next page.", + "schema": { + "type": "string" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "description": "The maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 100 + } + }, + "X-RateLimit-Remaining": { + "description": "The number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 95 + } + }, + "X-RateLimit-Reset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-01-15T11:00:00Z" + } + } + }, + "schemas": { + "V2File": { + "type": "object", + "description": "A workspace file as exposed by the v2 surface.", + "required": ["id", "name", "size", "type", "key", "uploadedBy", "uploadedAt"], + "properties": { + "id": { + "type": "string", + "description": "Unique file identifier.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "name": { + "type": "string", + "description": "Original filename.", + "example": "data.csv" + }, + "size": { + "type": "integer", + "minimum": 0, + "description": "File size in bytes.", + "example": 1024 + }, + "type": { + "type": "string", + "description": "MIME type of the file.", + "example": "text/csv" + }, + "key": { + "type": "string", + "description": "Storage key for the file.", + "example": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv" + }, + "uploadedBy": { + "type": "string", + "description": "User ID of the uploader.", + "example": "user_abc123" + }, + "uploadedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of when the file was uploaded.", + "example": "2026-01-15T10:30:00Z" + } + } + }, + "V2DeleteFileResult": { + "type": "object", + "description": "Acknowledgement returned by a successful archive (soft delete).", + "required": ["id", "deleted"], + "properties": { + "id": { + "type": "string", + "description": "The unique identifier of the archived file.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Always true on a successful archive." + } + } + }, + "V2AuditLogEntry": { + "type": "object", + "description": "A public enterprise audit log entry. The ipAddress and userAgent fields are intentionally excluded for privacy.", + "required": [ + "id", + "workspaceId", + "actorId", + "actorName", + "actorEmail", + "action", + "resourceType", + "resourceId", + "resourceName", + "description", + "createdAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the audit log entry.", + "example": "audit_2c3d4e5f6g" + }, + "workspaceId": { + "type": ["string", "null"], + "description": "The workspace where the action occurred, or null for organization-level actions.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "actorId": { + "type": ["string", "null"], + "description": "The user ID of the person who performed the action, or null when not attributable.", + "example": "user_abc123" + }, + "actorName": { + "type": ["string", "null"], + "description": "Display name of the person who performed the action.", + "example": "Jane Smith" + }, + "actorEmail": { + "type": ["string", "null"], + "description": "Email address of the person who performed the action.", + "example": "jane@example.com" + }, + "action": { + "type": "string", + "description": "The action that was performed (e.g., file.uploaded, workflow.deployed).", + "example": "file.uploaded" + }, + "resourceType": { + "type": "string", + "description": "The type of resource affected (e.g., file, workflow, workspace, member).", + "example": "file" + }, + "resourceId": { + "type": ["string", "null"], + "description": "The unique identifier of the affected resource.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "resourceName": { + "type": ["string", "null"], + "description": "Display name of the affected resource.", + "example": "data.csv" + }, + "description": { + "type": ["string", "null"], + "description": "Human-readable description of the action.", + "example": "Uploaded file \"data.csv\" via API" + }, + "metadata": { + "description": "Arbitrary per-action metadata as JSON. The shape varies by action type and may be null for some actions.", + "example": { + "fileSize": 1024, + "fileType": "text/csv" + } + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the action occurred.", + "example": "2026-01-15T10:30:00Z" + } + } + }, + "V2FileListResponse": { + "type": "object", + "description": "A page of files plus the cursor for the next page.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The files in this page.", + "items": { + "$ref": "#/components/schemas/V2File" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more results." + } + } + }, + "V2FileResponse": { + "type": "object", + "description": "A single file resource.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2File" + } + } + }, + "V2DeleteFileResponse": { + "type": "object", + "description": "The result of archiving a file.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2DeleteFileResult" + } + } + }, + "V2AuditLogListResponse": { + "type": "object", + "description": "A page of audit log entries plus the cursor for the next page.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The audit log entries in this page.", + "items": { + "$ref": "#/components/schemas/V2AuditLogEntry" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more results." + } + } + }, + "V2AuditLogResponse": { + "type": "object", + "description": "A single audit log entry resource.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2AuditLogEntry" + } + } + }, + "V2Error": { + "type": "object", + "description": "The canonical v2 error envelope.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Stable, machine-readable error code (e.g., BAD_REQUEST, NOT_FOUND, RATE_LIMITED)." + }, + "message": { + "type": "string", + "description": "Human-readable error message." + }, + "details": { + "description": "Optional structured error context. For validation errors this is an array of field-level issues; for rate limiting it carries the reset timestamp." + } + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Invalid request. Inspect `error.message` and the optional `error.details` for specifics.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { + "path": ["workspaceId"], + "code": "invalid_type", + "message": "Required" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + } + } + } + } + }, + "Forbidden": { + "description": "Access denied. For Files, the API key lacks access to the workspace. For Audit Logs, this requires an Enterprise subscription and an organization admin or owner role.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Active enterprise subscription required" + } + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found, or it does not belong to the authorized scope.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "File not found" + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration in the Retry-After header before retrying.", + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer", + "example": 30 + } + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-15T11:00:00Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected error occurred on the server.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json new file mode 100644 index 00000000000..5c43fd27ff7 --- /dev/null +++ b/apps/docs/openapi-v2-knowledge.json @@ -0,0 +1,1802 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 — Knowledge Bases", + "description": "The v2 Knowledge Bases API lets you create and manage knowledge bases, upload and inspect documents, and run vector and tag search over your indexed content.\n\n## Conventions\n\nAll endpoints live under the `/api/v2` base path and share a single set of conventions:\n\n- **Authentication** — Send your Sim API key in the `X-API-Key` header on every request. Keys are scoped to a workspace (or are personal keys that target a workspace); `workspaceId` is always required so the request can be tenant-scoped and rate-limited.\n- **Single-resource and mutation responses** return `{ \"data\": ... }`.\n- **List responses** use an opaque-cursor envelope: `{ \"data\": [ ... ], \"nextCursor\": string | null }`. Pass the returned `nextCursor` back as the `cursor` query parameter to fetch the next page. When `nextCursor` is `null` there are no more results. Cursors are opaque — do not parse or construct them.\n- **Errors** use a single envelope: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`. The HTTP status code and the stable `code` field move together (for example `404` ⇄ `NOT_FOUND`).\n- **Rate limiting** — Every response carries the current limiter state in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. A throttled request returns `429` with a `Retry-After` header.", + "version": "2.0.0", + "contact": { + "name": "Sim Support", + "email": "help@sim.ai", + "url": "https://www.sim.ai" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Knowledge Bases", + "description": "Create and manage knowledge bases, upload and inspect documents, and run vector and tag search (v2 API)." + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/v2/knowledge": { + "get": { + "operationId": "listKnowledgeBases", + "summary": "List Knowledge Bases", + "description": "List all knowledge bases in a workspace. The full bounded per-workspace set is returned as a single page, so `nextCursor` is always `null` today; treat the response as a standard cursor list so pagination can be added later without a contract change.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "Knowledge bases for the workspace.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The knowledge bases in the workspace.", + "items": { + "$ref": "#/components/schemas/KnowledgeBase" + } + }, + "nextCursor": { + "$ref": "#/components/schemas/NextCursor" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createKnowledgeBase", + "summary": "Create Knowledge Base", + "description": "Create a new knowledge base in a workspace. The embedding model and dimension are fixed server-side and cannot be supplied. Returns `201` with the created knowledge base.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/knowledge\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"Product Documentation\",\n \"description\": \"All product docs and guides\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The knowledge base to create.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateKnowledgeBaseBody" + } + } + } + }, + "responses": { + "201": { + "description": "The knowledge base was created.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeBaseEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/{id}": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + } + ], + "get": { + "operationId": "getKnowledgeBase", + "summary": "Get Knowledge Base", + "description": "Retrieve a single knowledge base by ID. A knowledge base that does not exist, belongs to another workspace, or that the caller cannot read is reported as `404` so cross-workspace existence is never leaked.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge/{id}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The knowledge base.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeBaseEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "operationId": "updateKnowledgeBase", + "summary": "Update Knowledge Base", + "description": "Update a knowledge base's name, description, or chunking config. At least one of `name`, `description`, or `chunkingConfig` must be provided. The target workspace is carried in the request body.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X PUT \\\n \"https://www.sim.ai/api/v2/knowledge/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"Updated name\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The fields to update. At least one of name, description, or chunkingConfig is required.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateKnowledgeBaseBody" + } + } + } + }, + "responses": { + "200": { + "description": "The updated knowledge base.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeBaseEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteKnowledgeBase", + "summary": "Delete Knowledge Base", + "description": "Delete a knowledge base and all of its documents. Returns a delete acknowledgement with the id of the removed knowledge base.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/knowledge/{id}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The knowledge base was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/search": { + "post": { + "operationId": "searchKnowledge", + "summary": "Search Knowledge", + "description": "Run vector and/or tag search across one or more knowledge bases. Provide a `query` for semantic vector search, `tagFilters` for structured filtering, or both. At least one of `query` or `tagFilters` is required.\n\nNotes and limits:\n- Tag filters are only supported when searching a single knowledge base.\n- When a `query` is supplied, all targeted knowledge bases must use the same embedding model; otherwise the request is rejected. Search such knowledge bases separately.\n- A text query consumes hosted embedding (and optional rerank) usage; tag-only search is free.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/knowledge/search\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"knowledgeBaseIds\": [\"KB_ID\"],\n \"query\": \"How do I reset my password?\",\n \"topK\": 10\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The search request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchBody" + } + } + } + }, + "responses": { + "200": { + "description": "Search results.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchEnvelope" + } + } + } + }, + "400": { + "description": "Invalid request. Returned when neither `query` nor `tagFilters` is provided, when tag filters target more than one knowledge base, when the selected knowledge bases use different embedding models, or when a tag name/value is invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "crossModel": { + "summary": "Knowledge bases use different embedding models", + "value": { + "error": { + "code": "BAD_REQUEST", + "message": "Selected knowledge bases use different embedding models and cannot be searched together. Search them separately." + } + } + }, + "multiKbTagFilter": { + "summary": "Tag filters across multiple knowledge bases", + "value": { + "error": { + "code": "BAD_REQUEST", + "message": "Tag filters are only supported when searching a single knowledge base" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "description": "One or more of the requested knowledge bases do not exist or are not accessible from this workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Knowledge base not found or access denied" + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/{id}/documents": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "get": { + "operationId": "listKnowledgeDocuments", + "summary": "List Documents", + "description": "List documents in a knowledge base. Supports search, enabled-state filtering, sorting, and cursor pagination. Pass the returned `nextCursor` back as `cursor` to fetch the next page; the total document count is available as `docCount` on the parent knowledge base.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents?workspaceId=YOUR_WORKSPACE_ID&limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of documents to return per page.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor from a previous response's `nextCursor`. Omit for the first page.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against document filenames.", + "schema": { + "type": "string" + } + }, + { + "name": "enabledFilter", + "in": "query", + "required": false, + "description": "Filter documents by their enabled state.", + "schema": { + "type": "string", + "enum": ["all", "enabled", "disabled"], + "default": "all" + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by.", + "schema": { + "type": "string", + "enum": [ + "filename", + "fileSize", + "tokenCount", + "chunkCount", + "uploadedAt", + "processingStatus", + "enabled" + ], + "default": "uploadedAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc" + } + } + ], + "responses": { + "200": { + "description": "Documents in the knowledge base.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The documents on this page.", + "items": { + "$ref": "#/components/schemas/DocumentSummary" + } + }, + "nextCursor": { + "$ref": "#/components/schemas/NextCursor" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "uploadKnowledgeDocument", + "summary": "Upload Document", + "description": "Upload a single document to a knowledge base as `multipart/form-data`. The workspace is supplied as the `workspaceId` query parameter (not a form field) so authorization runs before the file body is buffered. The maximum file size is 100 MB. Processing is asynchronous: the document is returned with `processingStatus: \"pending\"` and indexing continues in the background — poll the Get Document endpoint to observe progress.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -F \"file=@/path/to/document.pdf\"" + } + ], + "requestBody": { + "required": true, + "description": "The file to upload.", + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": ["file"], + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "The document file to upload (max 100 MB)." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The document was accepted and queued for processing.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentSummaryEnvelope" + } + } + } + }, + "400": { + "description": "Invalid request. Returned when the body is not valid multipart form data or the required `file` field is missing.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "file form field is required" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "description": "The uploaded file exceeds the 100 MB limit, or the workspace storage limit has been reached.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "File size exceeds 100MB limit (123.45MB)" + } + } + } + } + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/{id}/documents/{documentId}": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + }, + { + "$ref": "#/components/parameters/DocumentId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "get": { + "operationId": "getKnowledgeDocument", + "summary": "Get Document", + "description": "Retrieve the full detail for a single document, including processing state and connector provenance.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents/{documentId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "responses": { + "200": { + "description": "The document detail.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteKnowledgeDocument", + "summary": "Delete Document", + "description": "Delete a single document from a knowledge base. Returns a delete acknowledgement with the id of the removed document.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents/{documentId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "responses": { + "200": { + "description": "The document was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "KnowledgeBaseId": { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the knowledge base.", + "schema": { + "type": "string", + "minLength": 1, + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + } + }, + "DocumentId": { + "name": "documentId", + "in": "path", + "required": true, + "description": "The unique identifier of the document.", + "schema": { + "type": "string", + "minLength": 1, + "example": "b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12" + } + }, + "WorkspaceIdQuery": { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "The unique identifier of the workspace that scopes the request.", + "schema": { + "type": "string", + "minLength": 1, + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + } + }, + "headers": { + "RateLimitLimit": { + "description": "The maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 60 + } + }, + "RateLimitRemaining": { + "description": "The number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 59 + } + }, + "RateLimitReset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "example": "2025-06-20T14:16:00Z" + } + }, + "RetryAfter": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer", + "example": 30 + } + } + }, + "schemas": { + "NextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more results. Pass it back as the `cursor` query parameter. Do not parse or construct cursors.", + "example": null + }, + "ChunkingConfig": { + "type": "object", + "description": "How documents in this knowledge base are split into chunks before embedding.", + "required": ["maxSize", "minSize", "overlap"], + "additionalProperties": true, + "properties": { + "maxSize": { + "type": "integer", + "description": "Maximum chunk size, in tokens.", + "example": 1024 + }, + "minSize": { + "type": "integer", + "description": "Minimum chunk size, in characters.", + "example": 100 + }, + "overlap": { + "type": "integer", + "description": "Number of overlapping characters between adjacent chunks.", + "example": 200 + }, + "strategy": { + "type": "string", + "description": "Chunking strategy applied during processing.", + "enum": ["auto", "text", "regex", "recursive", "sentence", "token"] + } + } + }, + "ChunkingConfigInput": { + "type": "object", + "description": "Chunking configuration for the knowledge base. Defaults are applied when omitted.", + "properties": { + "maxSize": { + "type": "integer", + "description": "Maximum chunk size, in tokens.", + "minimum": 100, + "maximum": 4000, + "default": 1024 + }, + "minSize": { + "type": "integer", + "description": "Minimum chunk size, in characters.", + "minimum": 1, + "maximum": 2000, + "default": 100 + }, + "overlap": { + "type": "integer", + "description": "Number of overlapping characters between adjacent chunks.", + "minimum": 0, + "maximum": 500, + "default": 200 + } + } + }, + "KnowledgeBase": { + "type": "object", + "description": "A knowledge base: a collection of documents indexed for vector and tag search.", + "required": [ + "id", + "name", + "description", + "tokenCount", + "embeddingModel", + "embeddingDimension", + "chunkingConfig", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique knowledge base identifier.", + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + }, + "name": { + "type": "string", + "description": "Human-readable knowledge base name.", + "example": "Product Documentation" + }, + "description": { + "type": ["string", "null"], + "description": "Optional description of the knowledge base. null when not set.", + "example": "All product docs and guides" + }, + "tokenCount": { + "type": "integer", + "description": "Total number of tokens across all indexed documents.", + "example": 48213 + }, + "embeddingModel": { + "type": "string", + "description": "The embedding model used to index documents in this knowledge base.", + "example": "text-embedding-3-small" + }, + "embeddingDimension": { + "type": "integer", + "description": "The dimensionality of the embedding vectors.", + "example": 1536 + }, + "chunkingConfig": { + "$ref": "#/components/schemas/ChunkingConfig" + }, + "docCount": { + "type": "integer", + "description": "Number of documents in the knowledge base.", + "example": 12 + }, + "connectorTypes": { + "type": "array", + "description": "The set of external connector types that have synced documents into this knowledge base.", + "items": { + "type": "string" + }, + "example": ["notion", "google_drive"] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the knowledge base was created.", + "example": "2025-01-10T09:00:00Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the knowledge base was last modified.", + "example": "2025-06-18T16:45:00Z" + } + } + }, + "KnowledgeBaseEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["knowledgeBase"], + "properties": { + "knowledgeBase": { + "$ref": "#/components/schemas/KnowledgeBase" + } + } + } + } + }, + "CreateKnowledgeBaseBody": { + "type": "object", + "description": "Request body for creating a knowledge base.", + "required": ["workspaceId", "name"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace the knowledge base belongs to.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable knowledge base name.", + "example": "Product Documentation" + }, + "description": { + "type": "string", + "maxLength": 1000, + "description": "Optional description of the knowledge base.", + "example": "All product docs and guides" + }, + "chunkingConfig": { + "$ref": "#/components/schemas/ChunkingConfigInput" + } + } + }, + "UpdateKnowledgeBaseBody": { + "type": "object", + "description": "Request body for updating a knowledge base. At least one of name, description, or chunkingConfig must be provided.", + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace the knowledge base belongs to.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "New knowledge base name.", + "example": "Updated Product Documentation" + }, + "description": { + "type": "string", + "maxLength": 1000, + "description": "New description of the knowledge base.", + "example": "Refreshed product docs and guides" + }, + "chunkingConfig": { + "$ref": "#/components/schemas/ChunkingConfigInput" + } + } + }, + "DocumentSummary": { + "type": "object", + "description": "Summary representation of a document, returned in list operations and as the upload acknowledgement.", + "required": [ + "id", + "knowledgeBaseId", + "filename", + "fileSize", + "mimeType", + "processingStatus", + "chunkCount", + "tokenCount", + "characterCount", + "enabled", + "createdAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique document identifier.", + "example": "b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12" + }, + "knowledgeBaseId": { + "type": "string", + "description": "The knowledge base this document belongs to.", + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + }, + "filename": { + "type": "string", + "description": "Original filename of the uploaded document.", + "example": "getting-started.pdf" + }, + "fileSize": { + "type": "integer", + "description": "Size of the file in bytes.", + "example": 248913 + }, + "mimeType": { + "type": "string", + "description": "MIME type of the file.", + "example": "application/pdf" + }, + "processingStatus": { + "type": "string", + "description": "Current processing state of the document.", + "enum": ["pending", "processing", "completed", "failed"], + "example": "completed" + }, + "chunkCount": { + "type": "integer", + "description": "Number of chunks the document was split into. 0 until processing completes.", + "example": 24 + }, + "tokenCount": { + "type": "integer", + "description": "Total number of tokens extracted from the document.", + "example": 8123 + }, + "characterCount": { + "type": "integer", + "description": "Total number of characters extracted from the document.", + "example": 41205 + }, + "enabled": { + "type": "boolean", + "description": "Whether the document is enabled for search.", + "example": true + }, + "createdAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when the document was uploaded.", + "example": "2025-06-18T16:45:00Z" + } + } + }, + "DocumentSummaryEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["document"], + "properties": { + "document": { + "$ref": "#/components/schemas/DocumentSummary" + } + } + } + } + }, + "Document": { + "type": "object", + "description": "Full document detail: the summary fields plus processing state and connector provenance.", + "required": [ + "id", + "knowledgeBaseId", + "filename", + "fileSize", + "mimeType", + "processingStatus", + "chunkCount", + "tokenCount", + "characterCount", + "enabled", + "createdAt", + "processingError", + "processingStartedAt", + "processingCompletedAt", + "connectorId", + "connectorType", + "sourceUrl" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique document identifier.", + "example": "b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12" + }, + "knowledgeBaseId": { + "type": "string", + "description": "The knowledge base this document belongs to.", + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + }, + "filename": { + "type": "string", + "description": "Original filename of the uploaded document.", + "example": "getting-started.pdf" + }, + "fileSize": { + "type": "integer", + "description": "Size of the file in bytes.", + "example": 248913 + }, + "mimeType": { + "type": "string", + "description": "MIME type of the file.", + "example": "application/pdf" + }, + "processingStatus": { + "type": "string", + "description": "Current processing state of the document.", + "enum": ["pending", "processing", "completed", "failed"], + "example": "completed" + }, + "chunkCount": { + "type": "integer", + "description": "Number of chunks the document was split into. 0 until processing completes.", + "example": 24 + }, + "tokenCount": { + "type": "integer", + "description": "Total number of tokens extracted from the document.", + "example": 8123 + }, + "characterCount": { + "type": "integer", + "description": "Total number of characters extracted from the document.", + "example": 41205 + }, + "enabled": { + "type": "boolean", + "description": "Whether the document is enabled for search.", + "example": true + }, + "createdAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when the document was uploaded.", + "example": "2025-06-18T16:45:00Z" + }, + "processingError": { + "type": ["string", "null"], + "description": "Error message if processing failed, otherwise null.", + "example": null + }, + "processingStartedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when processing started, or null.", + "example": "2025-06-18T16:45:05Z" + }, + "processingCompletedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when processing completed, or null.", + "example": "2025-06-18T16:45:42Z" + }, + "connectorId": { + "type": ["string", "null"], + "description": "Identifier of the connector that synced this document, or null for direct uploads.", + "example": null + }, + "connectorType": { + "type": ["string", "null"], + "description": "Type of the connector that synced this document, or null for direct uploads.", + "example": null + }, + "sourceUrl": { + "type": ["string", "null"], + "description": "Original source URL of the document for connector-synced documents, or null.", + "example": null + } + } + }, + "DocumentEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["document"], + "properties": { + "document": { + "$ref": "#/components/schemas/Document" + } + } + } + } + }, + "SearchTagFilter": { + "type": "object", + "description": "A structured tag filter applied to search. Tag filters are only supported when searching a single knowledge base.", + "required": ["tagName", "value"], + "properties": { + "tagName": { + "type": "string", + "description": "The display name of the tag to filter on.", + "example": "category" + }, + "fieldType": { + "type": "string", + "description": "The tag's field type.", + "enum": ["text", "number", "date", "boolean"] + }, + "operator": { + "type": "string", + "description": "Comparison operator. Valid operators depend on the field type.", + "default": "eq", + "example": "eq" + }, + "value": { + "description": "The value to compare against.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "example": "billing" + }, + "valueTo": { + "description": "Upper bound for the `between` operator (number or date).", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + } + }, + "SearchBody": { + "type": "object", + "description": "Request body for knowledge search. At least one of `query` or `tagFilters` must be provided.", + "required": ["workspaceId", "knowledgeBaseIds"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the knowledge bases.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "knowledgeBaseIds": { + "description": "A single knowledge base ID or an array of up to 20 IDs to search.", + "oneOf": [ + { + "type": "string", + "minLength": 1, + "description": "A single knowledge base ID." + }, + { + "type": "array", + "description": "An array of knowledge base IDs.", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1, + "maxItems": 20 + } + ], + "example": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] + }, + "query": { + "type": "string", + "description": "The natural-language query for semantic vector search. Required if `tagFilters` is omitted.", + "example": "How do I reset my password?" + }, + "topK": { + "type": "integer", + "description": "Maximum number of results to return.", + "minimum": 1, + "maximum": 100, + "default": 10 + }, + "tagFilters": { + "type": "array", + "description": "Structured tag filters. Only supported when searching a single knowledge base. Required if `query` is omitted.", + "items": { + "$ref": "#/components/schemas/SearchTagFilter" + } + } + } + }, + "SearchResult": { + "type": "object", + "description": "A single search hit (a matching document chunk).", + "required": [ + "documentId", + "documentName", + "sourceUrl", + "content", + "chunkIndex", + "metadata", + "similarity" + ], + "properties": { + "documentId": { + "type": "string", + "description": "Identifier of the document the chunk belongs to.", + "example": "b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12" + }, + "documentName": { + "type": ["string", "null"], + "description": "Filename of the source document, or null if unavailable.", + "example": "getting-started.pdf" + }, + "sourceUrl": { + "type": ["string", "null"], + "description": "Original source URL of the document, or null for direct uploads.", + "example": null + }, + "content": { + "type": "string", + "description": "The matching chunk's text content.", + "example": "To reset your password, open Settings and choose \"Security\"." + }, + "chunkIndex": { + "type": "integer", + "description": "Zero-based index of the chunk within its document.", + "example": 3 + }, + "metadata": { + "type": "object", + "description": "The document's tag values keyed by tag display name. Values are user-defined and may be strings, numbers, booleans, or dates.", + "additionalProperties": true, + "example": { + "category": "billing", + "priority": 2 + } + }, + "similarity": { + "type": "number", + "description": "Similarity score in the range 0–1 for vector search (higher is more similar). 1 for tag-only matches.", + "example": 0.8423 + } + } + }, + "SearchEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["results", "query", "knowledgeBaseIds", "topK", "totalResults"], + "properties": { + "results": { + "type": "array", + "description": "The matching chunks, ordered by relevance.", + "items": { + "$ref": "#/components/schemas/SearchResult" + } + }, + "query": { + "type": "string", + "description": "The query that was executed (empty string for tag-only search).", + "example": "How do I reset my password?" + }, + "knowledgeBaseIds": { + "type": "array", + "description": "The knowledge base IDs that were searched.", + "items": { + "type": "string" + }, + "example": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] + }, + "topK": { + "type": "integer", + "description": "The maximum number of results requested.", + "example": 10 + }, + "totalResults": { + "type": "integer", + "description": "The number of results returned.", + "example": 4 + } + } + } + } + }, + "DeleteEnvelope": { + "type": "object", + "description": "Delete acknowledgement.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id", "deleted"], + "properties": { + "id": { + "type": "string", + "description": "The id of the resource that was deleted.", + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + }, + "deleted": { + "type": "boolean", + "description": "Always true.", + "enum": [true], + "example": true + } + } + } + } + }, + "Error": { + "type": "object", + "description": "The canonical v2 error envelope.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Stable, machine-readable error code.", + "enum": [ + "BAD_REQUEST", + "UNAUTHORIZED", + "FORBIDDEN", + "NOT_FOUND", + "CONFLICT", + "PAYLOAD_TOO_LARGE", + "UNSUPPORTED_MEDIA_TYPE", + "USAGE_LIMIT_EXCEEDED", + "LOCKED", + "RATE_LIMITED", + "INTERNAL_ERROR" + ] + }, + "message": { + "type": "string", + "description": "Human-readable description of the error." + }, + "details": { + "description": "Optional structured context for the error, such as field-level validation issues." + } + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "The request was malformed or failed validation. Inspect `error.details` for field-level issues.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { + "path": "workspaceId", + "message": "workspaceId query parameter is required" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "The API key is missing or invalid. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + } + } + } + } + }, + "Forbidden": { + "description": "The authenticated caller does not have access to the requested workspace or resource.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Access denied" + } + } + } + } + }, + "NotFound": { + "description": "The requested resource does not exist or is not accessible from this workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Knowledge base not found" + } + } + } + } + }, + "Conflict": { + "description": "The request conflicts with the current state of the resource (for example, a resource with the same name already exists).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "Resource already exists" + } + } + } + } + }, + "UsageLimitExceeded": { + "description": "The workspace has exceeded its usage or billing limits. Upgrade the plan to continue.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "USAGE_LIMIT_EXCEEDED", + "message": "Usage limit exceeded. Please upgrade your plan to continue." + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The request payload exceeds the allowed size, or the workspace storage limit has been reached.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Storage limit exceeded" + } + } + } + } + }, + "UnsupportedMediaType": { + "description": "The uploaded file's MIME type or extension is not supported.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "UNSUPPORTED_MEDIA_TYPE", + "message": "Unsupported file type" + } + } + } + } + }, + "RateLimited": { + "description": "The rate limit has been exceeded. Retry after the period indicated by the Retry-After header.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/RetryAfter" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2025-06-20T14:16:00Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected error occurred on the server.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json new file mode 100644 index 00000000000..4631df64376 --- /dev/null +++ b/apps/docs/openapi-v2-logs.json @@ -0,0 +1,1065 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 — Logs", + "description": "Version 2 of the Sim API for workflow execution logs. v2 standardizes every response on a single envelope: a single resource returns `{ data }`, a list returns `{ data, nextCursor }`, and an error returns `{ error: { code, message, details? } }`. Lists use opaque cursor pagination (`limit` + `cursor` in, `nextCursor` out). Rate-limit state is carried in the `X-RateLimit-*` response headers rather than the body. Authenticate every request with the `X-API-Key` header.", + "version": "2.0.0", + "contact": { + "name": "Sim Support", + "email": "help@sim.ai", + "url": "https://www.sim.ai" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "security": [ + { + "apiKey": [] + } + ], + "tags": [ + { + "name": "Logs", + "description": "Query workflow execution logs, retrieve a single log entry, and fetch the full execution state snapshot for a run." + } + ], + "paths": { + "/api/v2/logs": { + "get": { + "operationId": "listLogs", + "summary": "List Logs", + "description": "List workflow execution logs for a workspace with filtering and opaque cursor pagination. Returns `{ data, nextCursor }`. By default (`details=basic`) each entry contains summary fields only; pass `details=full` to include the per-execution `workflow` summary, and additionally `includeFinalOutput=true` / `includeTraceSpans=true` to materialize `finalOutput` / `traceSpans` on each entry.", + "tags": ["Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/logs?workspaceId=YOUR_WORKSPACE_ID&limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "workflowIds", + "in": "query", + "description": "Comma-separated list of workflow IDs to filter by. Only logs from these workflows are returned.", + "schema": { + "type": "string" + }, + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36,8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + }, + { + "name": "folderIds", + "in": "query", + "description": "Comma-separated list of folder IDs. Returns logs for all workflows within these folders.", + "schema": { + "type": "string" + } + }, + { + "name": "triggers", + "in": "query", + "description": "Comma-separated trigger types to filter by (e.g. api, webhook, schedule, manual, chat).", + "schema": { + "type": "string" + }, + "example": "api,schedule" + }, + { + "name": "level", + "in": "query", + "description": "Filter logs by severity level. info for successful executions, error for failed ones.", + "schema": { + "type": "string", + "enum": ["info", "error"] + } + }, + { + "name": "startDate", + "in": "query", + "description": "Only return logs started at or after this ISO 8601 timestamp.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "endDate", + "in": "query", + "description": "Only return logs started at or before this ISO 8601 timestamp.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "executionId", + "in": "query", + "description": "Filter by an exact execution ID. Useful for looking up a specific run.", + "schema": { + "type": "string" + } + }, + { + "name": "minDurationMs", + "in": "query", + "description": "Only return logs where total execution duration was at least this many milliseconds.", + "schema": { + "type": "integer", + "minimum": 0 + } + }, + { + "name": "maxDurationMs", + "in": "query", + "description": "Only return logs where total execution duration was at most this many milliseconds.", + "schema": { + "type": "integer", + "minimum": 0 + } + }, + { + "name": "minCost", + "in": "query", + "description": "Only return logs where execution cost was at least this amount in USD.", + "schema": { + "type": "number", + "minimum": 0 + } + }, + { + "name": "maxCost", + "in": "query", + "description": "Only return logs where execution cost was at most this amount in USD.", + "schema": { + "type": "number", + "minimum": 0 + } + }, + { + "name": "model", + "in": "query", + "description": "Filter by the AI model used during execution (e.g., gpt-4o, claude-sonnet-4-20250514).", + "schema": { + "type": "string" + } + }, + { + "name": "details", + "in": "query", + "description": "Response detail level. basic returns summary fields only. full additionally includes the per-entry workflow summary and enables the includeFinalOutput / includeTraceSpans materialization flags.", + "schema": { + "type": "string", + "enum": ["basic", "full"], + "default": "basic" + } + }, + { + "name": "includeTraceSpans", + "in": "query", + "description": "When true, includes block-level execution trace spans on each entry. Only applies when details=full.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "includeFinalOutput", + "in": "query", + "description": "When true, includes the workflow's final output on each entry. Only applies when details=full.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum number of log entries to return per page. Values are clamped to the range 1–1000.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 100 + } + }, + { + "name": "cursor", + "in": "query", + "description": "Opaque pagination cursor returned from a previous request's nextCursor field. Omit to fetch the first page.", + "schema": { + "type": "string" + } + }, + { + "name": "order", + "in": "query", + "description": "Sort order by execution start time. desc returns newest first.", + "schema": { + "type": "string", + "enum": ["desc", "asc"], + "default": "desc" + } + } + ], + "responses": { + "200": { + "description": "A page of execution logs matching the filter criteria.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "Log entries for the current page.", + "items": { + "$ref": "#/components/schemas/LogListItem" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for fetching the next page. null when there are no more results." + } + } + }, + "example": { + "data": [ + { + "id": "log_7x8y9z0a1b", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "deploymentVersionId": "dep_2c4e6a8b0d1f", + "level": "info", + "trigger": "api", + "startedAt": "2026-01-15T10:30:00.000Z", + "endedAt": "2026-01-15T10:30:01.250Z", + "totalDurationMs": 1250, + "cost": { + "total": 0.0032 + }, + "files": null + } + ], + "nextCursor": "eyJzdGFydGVkQXQiOiIyMDI2LTAxLTE1VDEwOjMwOjAwLjAwMFoiLCJpZCI6ImxvZ183eDh5OXowYTFiIn0=" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/logs/{id}": { + "get": { + "operationId": "getLog", + "summary": "Get Log", + "description": "Retrieve a single log entry by its ID, including the workflow metadata captured at execution time, the materialized execution data, and the cost summary. Returns `{ data }`.", + "tags": ["Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/logs/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the log entry.", + "schema": { + "type": "string", + "example": "log_7x8y9z0a1b" + } + } + ], + "responses": { + "200": { + "description": "The requested log entry with full execution data and cost summary.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/LogDetail" + } + } + }, + "example": { + "data": { + "id": "log_7x8y9z0a1b", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "level": "info", + "trigger": "api", + "startedAt": "2026-01-15T10:30:00.000Z", + "endedAt": "2026-01-15T10:30:01.250Z", + "totalDurationMs": 1250, + "files": null, + "workflow": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer Support Agent", + "description": "Routes incoming support tickets and drafts responses", + "folderId": null, + "userId": "usr_1a2b3c4d5e", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "createdAt": "2025-01-10T09:00:00.000Z", + "updatedAt": "2025-06-18T16:45:00.000Z", + "deleted": false + }, + "executionData": { + "traceSpans": [], + "finalOutput": { + "result": "Hello, world!" + } + }, + "cost": { + "total": 0.0032 + }, + "createdAt": "2026-01-15T10:30:00.000Z" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/logs/executions/{executionId}": { + "get": { + "operationId": "getExecution", + "summary": "Get Execution", + "description": "Retrieve the full execution state snapshot for a run: the workflow state captured at execution time plus execution metadata (trigger, timing, and cost). Returns `{ data }`.", + "tags": ["Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/logs/executions/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The unique execution identifier.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + } + ], + "responses": { + "200": { + "description": "The full execution state snapshot with workflow state and metadata.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/Execution" + } + } + }, + "example": { + "data": { + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "workflowState": { + "blocks": {}, + "edges": [], + "loops": {}, + "parallels": {} + }, + "executionMetadata": { + "trigger": "api", + "startedAt": "2026-01-15T10:30:00.000Z", + "endedAt": "2026-01-15T10:30:01.250Z", + "totalDurationMs": 1250, + "cost": { + "total": 0.0032 + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "WorkspaceId": { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "The unique identifier of the workspace whose logs to query." + } + }, + "headers": { + "X-RateLimit-Limit": { + "description": "Maximum number of requests allowed in the current rate-limit window.", + "schema": { + "type": "integer" + } + }, + "X-RateLimit-Remaining": { + "description": "Number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer" + } + }, + "X-RateLimit-Reset": { + "description": "ISO 8601 timestamp when the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + "Retry-After": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer" + } + } + }, + "schemas": { + "Cost": { + "type": ["object", "null"], + "description": "Aggregate execution cost in USD. null when no cost was recorded for the run.", + "required": ["total"], + "properties": { + "total": { + "type": "number", + "description": "Total cost of the execution in USD.", + "example": 0.0032 + } + } + }, + "LogWorkflowSummary": { + "type": "object", + "description": "Workflow summary captured at execution time. Present on a list entry only when details=full.", + "required": ["id", "name", "description", "deleted"], + "properties": { + "id": { + "type": ["string", "null"], + "description": "The workflow's identifier. null if the log is not associated with a workflow.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Workflow name. Falls back to \"Deleted Workflow\" when the workflow no longer exists.", + "example": "Customer Support Agent" + }, + "description": { + "type": ["string", "null"], + "description": "Workflow description, or null if none was set.", + "example": "Routes incoming support tickets and drafts responses" + }, + "deleted": { + "type": "boolean", + "description": "Whether the workflow has since been deleted.", + "example": false + } + } + }, + "LogWorkflowDetail": { + "type": "object", + "description": "Full workflow metadata captured at execution time.", + "required": [ + "id", + "name", + "description", + "folderId", + "userId", + "workspaceId", + "createdAt", + "updatedAt", + "deleted" + ], + "properties": { + "id": { + "type": ["string", "null"], + "description": "The workflow's identifier. null if the log is not associated with a workflow.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Workflow name. Falls back to \"Deleted Workflow\" when the workflow no longer exists.", + "example": "Customer Support Agent" + }, + "description": { + "type": ["string", "null"], + "description": "Workflow description, or null if none was set.", + "example": "Routes incoming support tickets and drafts responses" + }, + "folderId": { + "type": ["string", "null"], + "description": "The folder the workflow belongs to. null if at the workspace root or the workflow is gone.", + "example": null + }, + "userId": { + "type": ["string", "null"], + "description": "The user that owns the workflow. null if the workflow is gone.", + "example": "usr_1a2b3c4d5e" + }, + "workspaceId": { + "type": ["string", "null"], + "description": "The workspace the workflow belongs to. null if the workflow is gone.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "createdAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was created. null if the workflow is gone.", + "example": "2025-01-10T09:00:00.000Z" + }, + "updatedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was last modified. null if the workflow is gone.", + "example": "2025-06-18T16:45:00.000Z" + }, + "deleted": { + "type": "boolean", + "description": "Whether the workflow has since been deleted.", + "example": false + } + } + }, + "LogListItem": { + "type": "object", + "description": "Summary of a single workflow execution log entry returned by the list endpoint.", + "required": [ + "id", + "workflowId", + "executionId", + "deploymentVersionId", + "level", + "trigger", + "startedAt", + "endedAt", + "totalDurationMs", + "cost", + "files" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique log entry identifier.", + "example": "log_7x8y9z0a1b" + }, + "workflowId": { + "type": ["string", "null"], + "description": "The workflow that was executed. null if the log is not associated with a workflow.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "executionId": { + "type": "string", + "description": "Unique execution identifier for this run.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "deploymentVersionId": { + "type": ["string", "null"], + "description": "The deployment version that produced this run. null for runs not tied to a deployment.", + "example": "dep_2c4e6a8b0d1f" + }, + "level": { + "type": "string", + "description": "Log severity. info for successful executions, error for failures.", + "example": "info" + }, + "trigger": { + "type": "string", + "description": "How the execution was triggered (e.g., api, webhook, schedule, manual, chat).", + "example": "api" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2026-01-15T10:30:00.000Z" + }, + "endedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed. null if the run has not finished.", + "example": "2026-01-15T10:30:01.250Z" + }, + "totalDurationMs": { + "type": ["integer", "null"], + "description": "Total execution duration in milliseconds. null if the run has not finished.", + "example": 1250 + }, + "cost": { + "$ref": "#/components/schemas/Cost" + }, + "files": { + "type": ["array", "null"], + "description": "Attachment metadata for files produced during the run. null when the run produced no files.", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "workflow": { + "allOf": [ + { + "$ref": "#/components/schemas/LogWorkflowSummary" + } + ], + "description": "Workflow summary. Present only when details=full." + }, + "finalOutput": { + "type": "object", + "additionalProperties": true, + "description": "The workflow's final output. The shape depends on the workflow. Present only when details=full and includeFinalOutput=true." + }, + "traceSpans": { + "type": "array", + "description": "Block-level execution trace spans with timing, inputs, and outputs. Present only when details=full and includeTraceSpans=true.", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "LogDetail": { + "type": "object", + "description": "Detailed log entry with full workflow metadata, materialized execution data, and cost summary.", + "required": [ + "id", + "workflowId", + "executionId", + "level", + "trigger", + "startedAt", + "endedAt", + "totalDurationMs", + "files", + "workflow", + "executionData", + "cost", + "createdAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique log entry identifier.", + "example": "log_7x8y9z0a1b" + }, + "workflowId": { + "type": ["string", "null"], + "description": "The workflow that was executed. null if the log is not associated with a workflow.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "executionId": { + "type": "string", + "description": "Unique execution identifier for this run.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "level": { + "type": "string", + "description": "Log severity. info for successful executions, error for failures.", + "example": "info" + }, + "trigger": { + "type": "string", + "description": "How the execution was triggered (e.g., api, webhook, schedule, manual, chat).", + "example": "api" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2026-01-15T10:30:00.000Z" + }, + "endedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed. null if the run has not finished.", + "example": "2026-01-15T10:30:01.250Z" + }, + "totalDurationMs": { + "type": ["integer", "null"], + "description": "Total execution duration in milliseconds. null if the run has not finished.", + "example": 1250 + }, + "files": { + "type": ["array", "null"], + "description": "Attachment metadata for files produced during the run. null when the run produced no files.", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "workflow": { + "$ref": "#/components/schemas/LogWorkflowDetail" + }, + "executionData": { + "type": "object", + "additionalProperties": true, + "description": "Materialized execution trace for this run (block states, trace spans, and final output). Large blobs stored externally are resolved inline.", + "properties": { + "traceSpans": { + "type": "array", + "description": "Block-level execution traces with timing, inputs, and outputs.", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "finalOutput": { + "type": "object", + "additionalProperties": true, + "description": "The workflow's final output after all blocks completed." + } + } + }, + "cost": { + "$ref": "#/components/schemas/Cost" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the log entry was recorded.", + "example": "2026-01-15T10:30:00.000Z" + } + } + }, + "Execution": { + "type": "object", + "description": "Full execution state snapshot: the workflow state at execution time plus execution metadata.", + "required": ["executionId", "workflowId", "workflowState", "executionMetadata"], + "properties": { + "executionId": { + "type": "string", + "description": "The unique identifier for this execution.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "workflowId": { + "type": ["string", "null"], + "description": "The workflow that was executed. null if the log is not associated with a workflow.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "workflowState": { + "type": "object", + "additionalProperties": true, + "description": "Snapshot of the workflow configuration at the time of execution.", + "properties": { + "blocks": { + "type": "object", + "additionalProperties": true, + "description": "Map of block IDs to their configuration and state during execution." + }, + "edges": { + "type": "array", + "description": "Connections between blocks defining the execution flow.", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "loops": { + "type": "object", + "additionalProperties": true, + "description": "Loop configurations defining iterative execution patterns." + }, + "parallels": { + "type": "object", + "additionalProperties": true, + "description": "Parallel execution group configurations." + } + } + }, + "executionMetadata": { + "type": "object", + "description": "Metadata about the execution including trigger, timing, and cost.", + "required": ["trigger", "startedAt", "endedAt", "totalDurationMs", "cost"], + "properties": { + "trigger": { + "type": "string", + "description": "How the execution was triggered (e.g., api, webhook, schedule, manual, chat).", + "example": "api" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2026-01-15T10:30:00.000Z" + }, + "endedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed. null if the run has not finished.", + "example": "2026-01-15T10:30:01.250Z" + }, + "totalDurationMs": { + "type": ["integer", "null"], + "description": "Total execution duration in milliseconds. null if the run has not finished.", + "example": 1250 + }, + "cost": { + "$ref": "#/components/schemas/Cost" + } + } + } + } + }, + "Error": { + "type": "object", + "description": "Canonical v2 error envelope.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Machine-readable error code (e.g., BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, RATE_LIMITED, INTERNAL_ERROR).", + "example": "NOT_FOUND" + }, + "message": { + "type": "string", + "description": "Human-readable error message.", + "example": "Log not found" + }, + "details": { + "description": "Optional structured details about the error (e.g., field-level validation issues or rate-limit reset info). Present only on some errors." + } + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Invalid request parameters. Inspect error.details for field-level validation issues.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { + "path": "workspaceId", + "message": "Workspace ID is required" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "API key required" + } + } + } + } + }, + "Forbidden": { + "description": "The API key is authenticated but not authorized for the requested workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "API key is not authorized for this workspace" + } + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found. An authorization failure on a single resource is also reported as 404 so resource existence is not leaked.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Log not found" + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration in the Retry-After header before retrying.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-15T10:31:00.000Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected error occurred while processing the request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json new file mode 100644 index 00000000000..fa3daf0cd97 --- /dev/null +++ b/apps/docs/openapi-v2-tables.json @@ -0,0 +1,2339 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim Tables API v2", + "description": "Version 2 of the Sim Tables API for managing tables, their column schemas, and rows of structured data. v2 standardizes every endpoint on a single response family: a single resource is returned as `{ data }`, lists are returned as `{ data, nextCursor }` with opaque cursor pagination, and errors are returned as `{ error: { code, message, details? } }`. Rate-limit state is carried in `X-RateLimit-*` response headers. Authenticate every request with the `X-API-Key` header. Row `data` is always keyed by column name.", + "version": "2.0.0", + "contact": { + "name": "Sim Support", + "email": "help@sim.ai", + "url": "https://www.sim.ai" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Tables", + "description": "Manage tables, columns, and rows for structured data storage (v2 API)." + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/v2/tables": { + "get": { + "operationId": "listTables", + "summary": "List Tables", + "description": "List all tables in a workspace. Returns the full bounded set of tables for the workspace as a single page, so `nextCursor` is always null.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The tables in the workspace.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableListEnvelope" + }, + "example": { + "data": [ + { + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "contacts", + "description": "Customer contact records", + "schema": { + "columns": [ + { + "id": "col_a1b2c3", + "name": "email", + "type": "string", + "required": true, + "unique": true + }, + { + "id": "col_d4e5f6", + "name": "name", + "type": "string", + "required": true + } + ] + }, + "rowCount": 2, + "maxRows": 100000, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createTable", + "summary": "Create Table", + "description": "Create a new table with a typed column schema. The schema must contain between 1 and 50 columns.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"contacts\",\n \"description\": \"Customer contacts\",\n \"schema\": {\n \"columns\": [\n { \"name\": \"email\", \"type\": \"string\", \"required\": true, \"unique\": true },\n { \"name\": \"name\", \"type\": \"string\", \"required\": true },\n { \"name\": \"age\", \"type\": \"number\" }\n ]\n }\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The table name, optional description, column schema, and target workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTableBody" + } + } + } + }, + "responses": { + "201": { + "description": "The table was created.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableEnvelope" + }, + "example": { + "data": { + "table": { + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "contacts", + "description": "Customer contacts", + "schema": { + "columns": [ + { + "id": "col_a1b2c3", + "name": "email", + "type": "string", + "required": true, + "unique": true + }, + { + "id": "col_d4e5f6", + "name": "name", + "type": "string", + "required": true + }, + { + "id": "col_g7h8i9", + "name": "age", + "type": "number", + "required": false, + "unique": false + } + ] + }, + "rowCount": 0, + "maxRows": 100000, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}": { + "get": { + "operationId": "getTable", + "summary": "Get Table", + "description": "Get a single table's metadata and column schema.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The requested table.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTable", + "summary": "Delete Table", + "description": "Archive a table. Returns the id of the archived table.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The table was archived.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteTableEnvelope" + }, + "example": { + "data": { + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/columns": { + "post": { + "operationId": "addTableColumn", + "summary": "Add Column", + "description": "Add a column to the table schema. Returns the table's full column list after the change.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/columns\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"column\": {\n \"name\": \"phone\",\n \"type\": \"string\",\n \"required\": false,\n \"unique\": false\n }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace and the column definition to add.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddColumnBody" + } + } + } + }, + "responses": { + "200": { + "description": "The column was added.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ColumnsEnvelope" + }, + "example": { + "data": { + "columns": [ + { + "id": "col_a1b2c3", + "name": "email", + "type": "string", + "required": true, + "unique": true + }, + { + "id": "col_d4e5f6", + "name": "name", + "type": "string", + "required": true, + "unique": false + }, + { + "id": "col_x9y8z7", + "name": "phone", + "type": "string", + "required": false, + "unique": false + } + ] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "updateTableColumn", + "summary": "Update Column", + "description": "Update a column by name — rename it, change its type, or toggle its required/unique constraints. Provide at least one field in `updates`. Returns the table's full column list after the change.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/columns\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"columnName\": \"phone\",\n \"updates\": {\n \"name\": \"phone_number\",\n \"required\": true\n }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace, the current column name, and the fields to change.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateColumnBody" + } + } + } + }, + "responses": { + "200": { + "description": "The column was updated.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ColumnsEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTableColumn", + "summary": "Delete Column", + "description": "Delete a column from the table schema by name. A table must always keep at least one column. Returns the table's full column list after the change.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/columns\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"columnName\": \"phone_number\"\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace and the name of the column to delete.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteColumnBody" + } + } + } + }, + "responses": { + "200": { + "description": "The column was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ColumnsEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/rows": { + "get": { + "operationId": "listTableRows", + "summary": "List Rows", + "description": "Query rows from a table with optional filtering, sorting, and cursor pagination. `filter` and `sort` are passed as JSON-encoded query parameters and key on column names. Pagination uses an opaque cursor: pass the `nextCursor` from a previous response to fetch the next page; `nextCursor` is null on the final page. Total row count is available as `rowCount` on the table resource.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows?workspaceId=YOUR_WORKSPACE_ID&limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "$ref": "#/components/parameters/FilterQuery" + }, + { + "$ref": "#/components/parameters/SortQuery" + }, + { + "$ref": "#/components/parameters/LimitQuery" + }, + { + "$ref": "#/components/parameters/CursorQuery" + } + ], + "responses": { + "200": { + "description": "Rows matching the query.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RowListEnvelope" + }, + "example": { + "data": [ + { + "id": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07", + "data": { + "email": "jane@example.com", + "name": "Jane Doe", + "age": 30 + }, + "position": 0, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + ], + "nextCursor": "eyJvZmZzZXQiOjUwfQ==" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createTableRows", + "summary": "Create Rows", + "description": "Insert one or many rows. Send a single-row body (`{ data }`) to insert one row, or a batch body (`{ rows }`) to insert up to 1000 rows in one request. The response shape mirrors the request: a single insert returns `{ data: { row } }`, a batch insert returns `{ data: { rows, insertedCount } }`. Row `data` is keyed by column name.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"data\": {\n \"email\": \"user@example.com\",\n \"name\": \"Jane Doe\"\n }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "Either a single-row payload or a batch payload.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRowsBody" + }, + "examples": { + "single": { + "summary": "Insert a single row", + "value": { + "workspaceId": "YOUR_WORKSPACE_ID", + "data": { + "email": "user@example.com", + "name": "Jane Doe" + } + } + }, + "batch": { + "summary": "Insert multiple rows", + "value": { + "workspaceId": "YOUR_WORKSPACE_ID", + "rows": [ + { + "email": "a@example.com", + "name": "Ada" + }, + { + "email": "b@example.com", + "name": "Babbage" + } + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The row(s) were inserted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRowsResponse" + }, + "examples": { + "single": { + "summary": "Single insert response", + "value": { + "data": { + "row": { + "id": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07", + "data": { + "email": "user@example.com", + "name": "Jane Doe" + }, + "position": 0, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + } + } + }, + "batch": { + "summary": "Batch insert response", + "value": { + "data": { + "rows": [ + { + "id": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "data": { + "email": "a@example.com", + "name": "Ada" + }, + "position": 0, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + }, + { + "id": "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85", + "data": { + "email": "b@example.com", + "name": "Babbage" + }, + "position": 1, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + ], + "insertedCount": 2 + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "operationId": "updateTableRows", + "summary": "Update Rows by Filter", + "description": "Bulk-update every row matching a filter, applying the same partial `data` patch to each. The filter must contain at least one condition. `updatedRowIds` is always returned (empty when nothing matched).", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PUT \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"filter\": { \"status\": \"pending\" },\n \"data\": { \"status\": \"active\" }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace, a non-empty filter, the patch data, and an optional row cap.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRowsByFilterBody" + } + } + } + }, + "responses": { + "200": { + "description": "The matching rows were updated.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRowsEnvelope" + }, + "example": { + "data": { + "updatedCount": 3, + "updatedRowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85", + "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + ] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTableRows", + "summary": "Delete Rows", + "description": "Delete rows in bulk, either by a non-empty filter or by an explicit list of row ids. Provide exactly one of `filter` or `rowIds`. For id-based deletes the response also reports `requestedCount` and any `missingRowIds`; these fields are omitted for filter-based deletes.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"rowIds\": [\"row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93\", \"row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85\"]\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace and either a non-empty filter or an explicit list of row ids.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteRowsBody" + }, + "examples": { + "byIds": { + "summary": "Delete specific rows by id", + "value": { + "workspaceId": "YOUR_WORKSPACE_ID", + "rowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ] + } + }, + "byFilter": { + "summary": "Delete rows matching a filter", + "value": { + "workspaceId": "YOUR_WORKSPACE_ID", + "filter": { + "status": "archived" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The rows were deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteRowsEnvelope" + }, + "examples": { + "byIds": { + "summary": "Id-based delete response", + "value": { + "data": { + "deletedCount": 2, + "deletedRowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ], + "requestedCount": 2, + "missingRowIds": [] + } + } + }, + "byFilter": { + "summary": "Filter-based delete response", + "value": { + "data": { + "deletedCount": 5, + "deletedRowIds": ["row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93"] + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/rows/{rowId}": { + "get": { + "operationId": "getTableRow", + "summary": "Get Row", + "description": "Get a single row by id.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/{rowId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/RowId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The requested row.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RowEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "updateTableRow", + "summary": "Update Row", + "description": "Partially update a single row by id. The `data` patch is keyed by column name and merges into the existing row.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/{rowId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"data\": { \"name\": \"Updated Name\" }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/RowId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace and the partial row data to apply.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRowBody" + } + } + } + }, + "responses": { + "200": { + "description": "The row was updated.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RowEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTableRow", + "summary": "Delete Row", + "description": "Delete a single row by id. Returns `deletedCount` and `deletedRowIds`, mirroring the bulk delete shape.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/{rowId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/RowId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The row was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteRowEnvelope" + }, + "example": { + "data": { + "deletedCount": 1, + "deletedRowIds": ["row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07"] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/rows/upsert": { + "post": { + "operationId": "upsertTableRow", + "summary": "Upsert Row", + "description": "Insert a row, or update the existing row that conflicts on a unique column. When `conflictTarget` is omitted the server resolves the conflict against the table's single unique column. The response reports whether the row was inserted or updated.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/upsert\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"data\": { \"email\": \"user@example.com\", \"name\": \"John\" },\n \"conflictTarget\": \"email\"\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace, the row data, and an optional unique column to resolve the conflict against.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertRowBody" + } + } + } + }, + "responses": { + "200": { + "description": "The row was inserted or updated.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertRowEnvelope" + }, + "example": { + "data": { + "row": { + "id": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07", + "data": { + "email": "user@example.com", + "name": "John" + }, + "position": 0, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + }, + "operation": "insert" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "TableId": { + "name": "tableId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "description": "The unique identifier of the table." + }, + "RowId": { + "name": "rowId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "description": "The unique identifier of the row." + }, + "WorkspaceIdQuery": { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "The unique identifier of the workspace that owns the table." + }, + "FilterQuery": { + "name": "filter", + "in": "query", + "required": false, + "description": "JSON-encoded filter object keyed by column name. Supports equality ({\"status\": \"active\"}), comparison operators ({\"age\": {\"$gt\": 18}}), and $and/$or composition.", + "schema": { + "type": "string" + } + }, + "SortQuery": { + "name": "sort", + "in": "query", + "required": false, + "description": "JSON-encoded sort object mapping column name to direction. Example: {\"created_at\": \"desc\"}.", + "schema": { + "type": "string" + } + }, + "LimitQuery": { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum rows to return (1-1000, default 100).", + "schema": { + "type": "integer", + "default": 100, + "minimum": 1, + "maximum": 1000 + } + }, + "CursorQuery": { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor. Pass the `nextCursor` from a previous response to fetch the next page. Omit for the first page.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "headers": { + "RateLimitLimit": { + "description": "Maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer" + } + }, + "RateLimitRemaining": { + "description": "Number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer" + } + }, + "RateLimitReset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + "RetryAfter": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer" + } + } + }, + "schemas": { + "V2Error": { + "type": "object", + "description": "Canonical v2 error envelope.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Machine-readable error code.", + "example": "BAD_REQUEST" + }, + "message": { + "type": "string", + "description": "Human-readable error message." + }, + "details": { + "description": "Optional structured error details, such as per-field validation issues." + } + } + } + } + }, + "Column": { + "type": "object", + "description": "A column definition in a table schema.", + "required": ["name", "type"], + "properties": { + "id": { + "type": "string", + "description": "Stable server-assigned column id. May be absent on legacy columns created before id backfill.", + "example": "col_a1b2c3" + }, + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "email" + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "date", "json"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "default": false, + "description": "Whether values in this column must be unique across all rows." + }, + "workflowGroupId": { + "type": "string", + "description": "Set when the column is the output of a workflow group." + } + } + }, + "ColumnInput": { + "type": "object", + "description": "Column definition supplied when creating a table or adding a column.", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "email" + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "date", "json"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "default": false, + "description": "Whether values in this column must be unique across all rows." + } + } + }, + "Table": { + "type": "object", + "description": "A user-defined table with a typed column schema.", + "required": [ + "id", + "name", + "description", + "schema", + "rowCount", + "maxRows", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique table identifier.", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "name": { + "type": "string", + "description": "Table name.", + "example": "contacts" + }, + "description": { + "type": ["string", "null"], + "description": "Optional description of the table. Null when not set.", + "example": "Customer contact records" + }, + "schema": { + "type": "object", + "description": "Table schema definition.", + "required": ["columns"], + "properties": { + "columns": { + "type": "array", + "description": "Array of column definitions for the table.", + "items": { + "$ref": "#/components/schemas/Column" + } + } + } + }, + "rowCount": { + "type": "integer", + "description": "Current number of rows in the table." + }, + "maxRows": { + "type": "integer", + "description": "Maximum rows allowed by the current billing plan." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the table was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the table was last modified." + } + } + }, + "RowData": { + "type": "object", + "additionalProperties": true, + "description": "Row cells keyed by column name. Each value is typed per its column definition.", + "example": { + "email": "jane@example.com", + "name": "Jane Doe", + "age": 30 + } + }, + "Row": { + "type": "object", + "description": "A single row in a table.", + "required": ["id", "data", "position", "createdAt", "updatedAt"], + "properties": { + "id": { + "type": "string", + "description": "Unique row identifier.", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "position": { + "type": "integer", + "description": "Row's position/order in the table." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the row was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the row was last modified." + } + } + }, + "Filter": { + "type": "object", + "additionalProperties": true, + "minProperties": 1, + "description": "Filter object keyed by column name. Supports equality ({\"status\": \"active\"}), comparison operators ({\"age\": {\"$gt\": 18}}), and $and/$or composition. Must contain at least one condition.", + "example": { + "status": "active" + } + }, + "CreateTableBody": { + "type": "object", + "description": "Payload to create a new table.", + "required": ["workspaceId", "name", "schema"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that will own the table." + }, + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 128, + "description": "Table name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "contacts" + }, + "description": { + "type": "string", + "maxLength": 500, + "description": "Optional description of the table." + }, + "schema": { + "type": "object", + "required": ["columns"], + "description": "The table's column schema.", + "properties": { + "columns": { + "type": "array", + "minItems": 1, + "maxItems": 50, + "description": "Column definitions. A table must have between 1 and 50 columns.", + "items": { + "$ref": "#/components/schemas/ColumnInput" + } + } + } + } + } + }, + "AddColumnBody": { + "type": "object", + "description": "Payload to add a column to a table.", + "required": ["workspaceId", "column"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "column": { + "type": "object", + "description": "The column definition to add.", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "phone" + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "date", "json"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "default": false, + "description": "Whether values in this column must be unique across all rows." + }, + "position": { + "type": "integer", + "minimum": 0, + "description": "Zero-based insert position in the column order. Appended at the end when omitted." + } + } + } + } + }, + "UpdateColumnBody": { + "type": "object", + "description": "Payload to update an existing column by name.", + "required": ["workspaceId", "columnName", "updates"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "columnName": { + "type": "string", + "description": "The current name of the column to update.", + "example": "phone" + }, + "updates": { + "type": "object", + "description": "Fields to change. Provide at least one.", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "New column name.", + "example": "phone_number" + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "date", "json"], + "description": "New data type for the column." + }, + "required": { + "type": "boolean", + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "description": "Whether values in this column must be unique across all rows." + } + } + } + } + }, + "DeleteColumnBody": { + "type": "object", + "description": "Payload to delete a column by name.", + "required": ["workspaceId", "columnName"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "columnName": { + "type": "string", + "description": "The name of the column to delete.", + "example": "phone_number" + } + } + }, + "CreateRowSingleBody": { + "type": "object", + "description": "Insert a single row.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "afterRowId": { + "type": "string", + "minLength": 1, + "description": "Insert directly after this row id. Mutually exclusive with beforeRowId." + }, + "beforeRowId": { + "type": "string", + "minLength": 1, + "description": "Insert directly before this row id. Mutually exclusive with afterRowId." + } + } + }, + "CreateRowBatchBody": { + "type": "object", + "description": "Insert multiple rows in one request.", + "required": ["workspaceId", "rows"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "rows": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "description": "Rows to insert. Each entry is keyed by column name. Up to 1000 rows per request.", + "items": { + "$ref": "#/components/schemas/RowData" + } + } + } + }, + "CreateRowsBody": { + "description": "Either a single-row payload or a batch payload.", + "oneOf": [ + { + "$ref": "#/components/schemas/CreateRowSingleBody" + }, + { + "$ref": "#/components/schemas/CreateRowBatchBody" + } + ] + }, + "UpdateRowsByFilterBody": { + "type": "object", + "description": "Bulk-update rows matching a filter.", + "required": ["workspaceId", "filter", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "filter": { + "$ref": "#/components/schemas/Filter" + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of matching rows to update." + } + } + }, + "DeleteRowsByFilterBody": { + "type": "object", + "description": "Delete rows matching a filter.", + "required": ["workspaceId", "filter"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "filter": { + "$ref": "#/components/schemas/Filter" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of matching rows to delete." + } + } + }, + "DeleteRowsByIdsBody": { + "type": "object", + "description": "Delete an explicit list of rows by id.", + "required": ["workspaceId", "rowIds"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "rowIds": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "description": "Row ids to delete. Up to 1000 ids per request.", + "items": { + "type": "string", + "minLength": 1 + } + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of rows to delete." + } + } + }, + "DeleteRowsBody": { + "description": "Provide exactly one of `filter` or `rowIds`.", + "oneOf": [ + { + "$ref": "#/components/schemas/DeleteRowsByFilterBody" + }, + { + "$ref": "#/components/schemas/DeleteRowsByIdsBody" + } + ] + }, + "UpdateRowBody": { + "type": "object", + "description": "Partial update for a single row.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "data": { + "$ref": "#/components/schemas/RowData" + } + } + }, + "UpsertRowBody": { + "type": "object", + "description": "Insert-or-update a row keyed by a unique column.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "conflictTarget": { + "type": "string", + "minLength": 1, + "description": "Name of the unique column to resolve the conflict against. When omitted, the server uses the table's single unique column." + } + } + }, + "TableEnvelope": { + "type": "object", + "description": "A single table wrapped in the v2 data envelope.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["table"], + "properties": { + "table": { + "$ref": "#/components/schemas/Table" + } + } + } + } + }, + "TableListEnvelope": { + "type": "object", + "description": "A page of tables. `nextCursor` is always null because the full bounded workspace set is returned in one page.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Table" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more pages." + } + } + }, + "DeleteTableEnvelope": { + "type": "object", + "description": "Confirmation that a table was archived.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "description": "The id of the archived table." + } + } + } + } + }, + "ColumnsEnvelope": { + "type": "object", + "description": "The table's full column list after a column mutation.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["columns"], + "properties": { + "columns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Column" + } + } + } + } + } + }, + "RowEnvelope": { + "type": "object", + "description": "A single row wrapped in the v2 data envelope.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["row"], + "properties": { + "row": { + "$ref": "#/components/schemas/Row" + } + } + } + } + }, + "RowListEnvelope": { + "type": "object", + "description": "A cursor-paginated page of rows.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Row" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null on the final page." + } + } + }, + "BatchInsertRowsEnvelope": { + "type": "object", + "description": "Result of a batch row insert.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["rows", "insertedCount"], + "properties": { + "rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Row" + } + }, + "insertedCount": { + "type": "integer", + "description": "Number of rows inserted." + } + } + } + } + }, + "CreateRowsResponse": { + "description": "A single-row insert returns `{ data: { row } }`; a batch insert returns `{ data: { rows, insertedCount } }`.", + "oneOf": [ + { + "$ref": "#/components/schemas/RowEnvelope" + }, + { + "$ref": "#/components/schemas/BatchInsertRowsEnvelope" + } + ] + }, + "UpdateRowsEnvelope": { + "type": "object", + "description": "Result of a bulk update-by-filter.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["updatedCount", "updatedRowIds"], + "properties": { + "updatedCount": { + "type": "integer", + "description": "Number of rows updated." + }, + "updatedRowIds": { + "type": "array", + "description": "Ids of the updated rows. Empty when nothing matched.", + "items": { + "type": "string" + } + } + } + } + } + }, + "DeleteRowsEnvelope": { + "type": "object", + "description": "Result of a bulk delete. `requestedCount` and `missingRowIds` are present only for id-based deletes.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["deletedCount", "deletedRowIds"], + "properties": { + "deletedCount": { + "type": "integer", + "description": "Number of rows deleted." + }, + "deletedRowIds": { + "type": "array", + "description": "Ids of the deleted rows.", + "items": { + "type": "string" + } + }, + "requestedCount": { + "type": "integer", + "description": "Number of row ids requested. Present only for id-based deletes." + }, + "missingRowIds": { + "type": "array", + "description": "Requested ids that did not exist. Present only for id-based deletes.", + "items": { + "type": "string" + } + } + } + } + } + }, + "DeleteRowEnvelope": { + "type": "object", + "description": "Result of a single-row delete.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["deletedCount", "deletedRowIds"], + "properties": { + "deletedCount": { + "type": "integer", + "description": "Always 1 when a row was deleted." + }, + "deletedRowIds": { + "type": "array", + "description": "The id of the deleted row.", + "items": { + "type": "string" + } + } + } + } + } + }, + "UpsertRowEnvelope": { + "type": "object", + "description": "Result of an upsert, including whether the row was inserted or updated.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["row", "operation"], + "properties": { + "row": { + "$ref": "#/components/schemas/Row" + }, + "operation": { + "type": "string", + "enum": ["insert", "update"], + "description": "Whether the row was inserted or updated." + } + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Invalid request. The request body, query parameters, or a JSON-encoded filter/sort failed validation. Inspect `error.details` for field-level issues.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { + "path": "schema.columns", + "message": "Table must have at least one column" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + } + } + } + } + }, + "Forbidden": { + "description": "Access denied. The API key cannot access the target workspace, or a plan limit (such as the maximum number of tables) has been reached.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Access denied" + } + } + } + } + }, + "NotFound": { + "description": "The requested table or row was not found. Verify the id is correct and belongs to the specified workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Table not found" + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration in the Retry-After header before retrying.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/RetryAfter" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-15T10:31:00.000Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected server error occurred.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json new file mode 100644 index 00000000000..341c14ceb5c --- /dev/null +++ b/apps/docs/openapi-v2-workflows.json @@ -0,0 +1,1024 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 — Workflows", + "description": "Version 2 of the Sim REST API for listing workflows, inspecting workflow detail, and managing deployments (deploy, undeploy, rollback).\n\nThe v2 surface standardizes on a single response family across every endpoint:\n- Single resource: `{ \"data\": T }`\n- List: `{ \"data\": T[], \"nextCursor\": string | null }`\n- Error: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`\n\nLists use an opaque cursor (Stripe/Slack-style): send `limit` and `cursor`, receive `{ data, nextCursor }`. Cursors are opaque tokens — pass back the `nextCursor` from the previous page verbatim and stop when it is `null`. Total counts are not returned on lists.\n\nRate-limit state is carried in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` response headers (not in the body). A throttled request returns `429` with a `Retry-After` header.\n\nAuthenticate every request with an API key in the `X-API-Key` header.", + "version": "2.0.0", + "contact": { + "name": "Sim Support", + "email": "help@sim.ai", + "url": "https://www.sim.ai" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Workflows", + "description": "List workflows, inspect workflow detail, and manage deployments (deploy, undeploy, rollback) on the v2 API." + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/v2/workflows": { + "get": { + "operationId": "listWorkflows", + "summary": "List Workflows", + "description": "Retrieve workflows in a workspace using opaque cursor-based pagination. Results are ordered deterministically; follow `nextCursor` to page through the full set, and stop when it is `null`.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "folderId", + "in": "query", + "required": false, + "description": "Filter results to only include workflows within this folder.", + "schema": { + "type": "string", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + } + }, + { + "name": "deployedOnly", + "in": "query", + "required": false, + "description": "When true, only return workflows that are currently deployed. Useful for listing workflows available for API execution.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of workflows to return per page. Must be between 1 and 100.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor returned from a previous request's `nextCursor` field. Omit for the first page.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "A page of workflows.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "Workflows for the current page.", + "items": { + "$ref": "#/components/schemas/WorkflowListItem" + } + }, + "nextCursor": { + "type": "string", + "nullable": true, + "description": "Opaque cursor for fetching the next page. `null` when there are no more results." + } + } + }, + "example": { + "data": [ + { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer Support Agent", + "description": "Routes incoming support tickets and drafts responses", + "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "runCount": 142, + "lastRunAt": "2026-06-20T14:15:22.000Z", + "createdAt": "2026-01-10T09:00:00.000Z", + "updatedAt": "2026-06-18T16:45:00.000Z" + } + ], + "nextCursor": "eyJzb3J0T3JkZXIiOjAsImNyZWF0ZWRBdCI6IjIwMjYtMDEtMTBUMDk6MDA6MDAuMDAwWiIsImlkIjoiM2IxZjdjOTIifQ==" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}": { + "get": { + "operationId": "getWorkflow", + "summary": "Get Workflow", + "description": "Retrieve a single workflow, including its workflow-level variables and trigger input field definitions. Returns 404 when the workflow does not exist or you do not have access to it (existence is not leaked).", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "responses": { + "200": { + "description": "The requested workflow.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/WorkflowDetail" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer Support Agent", + "description": "Routes incoming support tickets and drafts responses", + "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "runCount": 142, + "lastRunAt": "2026-06-20T14:15:22.000Z", + "variables": { + "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60": { + "id": "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60", + "name": "supportEmail", + "type": "string", + "value": "support@example.com" + } + }, + "inputs": [ + { + "name": "ticketBody", + "type": "string", + "description": "The raw text of the incoming support ticket." + } + ], + "createdAt": "2026-01-10T09:00:00.000Z", + "updatedAt": "2026-06-18T16:45:00.000Z" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}/deploy": { + "post": { + "operationId": "deployWorkflow", + "summary": "Deploy Workflow", + "description": "Deploy the workflow's current draft state. Creates a new deployment version, makes it live for API execution, and activates schedules and triggers. Optionally accepts a `name` and `description` for the new version; the request body may be omitted entirely. Returns 404 when the workflow does not exist or you do not have access to it, and 423 when the workflow is locked.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/workflows/{id}/deploy\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"name\": \"Release 4\", \"description\": \"Fixes the agent prompt\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "requestBody": { + "required": false, + "description": "Optional metadata for the new deployment version. The request body may be omitted entirely.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Optional label for the new deployment version.", + "example": "Release 4" + }, + "description": { + "type": "string", + "maxLength": 50000, + "nullable": true, + "description": "Optional summary of what changed in this version.", + "example": "Fixes the agent prompt" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Workflow deployed successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/DeployResult" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "version": 4, + "warnings": [] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "undeployWorkflow", + "summary": "Undeploy Workflow", + "description": "Take the workflow offline. API execution stops and schedules, webhooks, and other deployment side effects are removed. Deployment versions are retained, so the workflow can be deployed again later. Returns 400 when the workflow is not currently deployed, 404 when the workflow does not exist or you do not have access to it, and 423 when the workflow is locked.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/workflows/{id}/deploy\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "responses": { + "200": { + "description": "Workflow undeployed successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/UndeployResult" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": false, + "deployedAt": null, + "warnings": [] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}/rollback": { + "post": { + "operationId": "rollbackWorkflow", + "summary": "Rollback Workflow", + "description": "Roll the live deployment back to a previous deployment version. The workflow must currently be deployed. By default the version immediately preceding the currently active one is re-activated; pass `version` to target a specific deployment version instead. The workflow's draft state is not modified. Returns 400 when the workflow is not deployed or there is no version to roll back to, 404 when the workflow does not exist or you do not have access to it, and 423 when the workflow is locked.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/workflows/{id}/rollback\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"version\": 3}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "requestBody": { + "required": false, + "description": "Optional rollback target. The request body may be omitted entirely to roll back to the version immediately preceding the active one.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "version": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647, + "description": "The deployment version to re-activate. Defaults to the version immediately preceding the active one.", + "example": 3 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Workflow rolled back successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/RollbackResult" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "version": 3, + "warnings": [] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "WorkspaceId": { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "The unique identifier of the workspace to list workflows from.", + "schema": { + "type": "string", + "minLength": 1, + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + }, + "WorkflowId": { + "name": "id", + "in": "path", + "required": true, + "description": "The unique workflow identifier.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + } + }, + "headers": { + "RateLimitLimit": { + "description": "The maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 60 + } + }, + "RateLimitRemaining": { + "description": "The number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 59 + } + }, + "RateLimitReset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-06-29T21:50:00.000Z" + } + } + }, + "schemas": { + "Error": { + "type": "object", + "description": "Canonical v2 error envelope. Every non-2xx response uses this shape.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Stable, machine-readable error code.", + "enum": [ + "BAD_REQUEST", + "UNAUTHORIZED", + "USAGE_LIMIT_EXCEEDED", + "FORBIDDEN", + "NOT_FOUND", + "CONFLICT", + "PAYLOAD_TOO_LARGE", + "UNSUPPORTED_MEDIA_TYPE", + "LOCKED", + "RATE_LIMITED", + "INTERNAL_ERROR" + ] + }, + "message": { + "type": "string", + "description": "Human-readable description of what went wrong." + }, + "details": { + "description": "Optional structured detail about the error (e.g. field-level validation issues). Shape varies by error code; absent when there is nothing to add." + } + } + } + } + }, + "WorkflowListItem": { + "type": "object", + "description": "Summary representation of a workflow returned by the list endpoint.", + "required": [ + "id", + "name", + "description", + "folderId", + "workspaceId", + "isDeployed", + "deployedAt", + "runCount", + "lastRunAt", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Human-readable workflow name.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Optional description of what the workflow does. `null` when unset.", + "example": "Routes incoming support tickets and drafts responses" + }, + "folderId": { + "type": "string", + "nullable": true, + "description": "The folder this workflow belongs to. `null` when at the workspace root.", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + }, + "workspaceId": { + "type": "string", + "description": "The workspace this workflow belongs to.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow is currently deployed and available for API execution.", + "example": true + }, + "deployedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the most recent deployment. `null` when never deployed.", + "example": "2026-06-12T10:30:00.000Z" + }, + "runCount": { + "type": "integer", + "description": "Total number of times this workflow has been executed.", + "example": 142 + }, + "lastRunAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the most recent execution. `null` when never run.", + "example": "2026-06-20T14:15:22.000Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was created.", + "example": "2026-01-10T09:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was last modified.", + "example": "2026-06-18T16:45:00.000Z" + } + } + }, + "WorkflowInputField": { + "type": "object", + "description": "A single trigger input field extracted from the workflow's input-definition block. Use these to construct the `input` object when executing the workflow.", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "description": "Field name as referenced by the workflow.", + "example": "ticketBody" + }, + "type": { + "type": "string", + "description": "Declared field type (e.g. `string`, `number`, `boolean`, `object`).", + "example": "string" + }, + "description": { + "type": "string", + "description": "Optional human-readable description of the field.", + "example": "The raw text of the incoming support ticket." + } + } + }, + "WorkflowDetail": { + "type": "object", + "description": "Full workflow representation: every list field plus workflow-level variables and trigger input field definitions.", + "required": [ + "id", + "name", + "description", + "folderId", + "workspaceId", + "isDeployed", + "deployedAt", + "runCount", + "lastRunAt", + "variables", + "inputs", + "createdAt", + "updatedAt" + ], + "allOf": [ + { + "$ref": "#/components/schemas/WorkflowListItem" + }, + { + "type": "object", + "required": ["variables", "inputs"], + "properties": { + "variables": { + "type": "object", + "description": "Workflow-scoped variables keyed by variable id. Each value is a structured variable object (`{ id, name, type, value, ... }`); only the inner `value` is user-defined. Empty object when the workflow defines no variables.", + "additionalProperties": true, + "example": { + "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60": { + "id": "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60", + "name": "supportEmail", + "type": "string", + "value": "support@example.com" + } + } + }, + "inputs": { + "type": "array", + "description": "The workflow's trigger input field definitions.", + "items": { + "$ref": "#/components/schemas/WorkflowInputField" + } + } + } + } + ] + }, + "DeploymentState": { + "type": "object", + "description": "Base deployment state shared by deploy, undeploy, and rollback results.", + "required": ["id", "isDeployed", "deployedAt", "warnings"], + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow is deployed and available for API execution after the operation." + }, + "deployedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the active deployment. `null` when the workflow is not deployed.", + "example": "2026-06-12T10:30:00.000Z" + }, + "warnings": { + "type": "array", + "description": "Non-fatal warnings. Present when trigger, schedule, or MCP side-effect sync is still in progress or needs a redeploy. Empty array when there is nothing to report.", + "items": { + "type": "string" + } + } + } + }, + "DeployResult": { + "description": "Deployment state returned after a successful deploy. `isDeployed` is always `true`.", + "allOf": [ + { + "$ref": "#/components/schemas/DeploymentState" + }, + { + "type": "object", + "properties": { + "version": { + "type": "integer", + "description": "The deployment version that is now active. May be omitted when the version number is unavailable.", + "example": 4 + } + } + } + ] + }, + "UndeployResult": { + "description": "Deployment state returned after a successful undeploy. `isDeployed` is always `false`, `deployedAt` is always `null`, and no `version` is included.", + "allOf": [ + { + "$ref": "#/components/schemas/DeploymentState" + } + ] + }, + "RollbackResult": { + "description": "Deployment state returned after a successful rollback. `isDeployed` is always `true` and `version` identifies the re-activated deployment version.", + "allOf": [ + { + "$ref": "#/components/schemas/DeploymentState" + }, + { + "type": "object", + "required": ["version"], + "properties": { + "version": { + "type": "integer", + "description": "The deployment version that was re-activated.", + "example": 3 + } + } + } + ] + } + }, + "responses": { + "BadRequest": { + "description": "The request was malformed or failed validation. Inspect `error.details` for field-level issues. Also returned when an operation is not allowed in the current state (e.g. undeploying a workflow that is not deployed).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "workspaceId is required", + "details": [ + { + "path": ["workspaceId"], + "message": "workspaceId is required" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the `X-API-Key` header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + } + } + } + } + }, + "Forbidden": { + "description": "Access denied. You do not have permission to access the requested workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Access denied" + } + } + } + } + }, + "NotFound": { + "description": "The workflow does not exist or you do not have access to it. Existence is not leaked, so an access failure is reported as 404.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Workflow not found" + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The request body exceeds the maximum allowed size.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } + } + } + } + }, + "Locked": { + "description": "The workflow is locked and cannot be modified. Wait for the in-progress operation to finish, then retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "LOCKED", + "message": "Workflow is locked and cannot be modified" + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration specified in the `Retry-After` header before retrying.", + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer", + "example": 30 + } + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-06-29T21:50:00.000Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected error occurred while processing the request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + } + } + } +} diff --git a/apps/sim/app/api/v1/admin/audit-logs/route.ts b/apps/sim/app/api/v1/admin/audit-logs/route.ts index 9610232d357..f3dbc231e69 100644 --- a/apps/sim/app/api/v1/admin/audit-logs/route.ts +++ b/apps/sim/app/api/v1/admin/audit-logs/route.ts @@ -31,21 +31,13 @@ import { internalErrorResponse, listResponse, } from '@/app/api/v1/admin/responses' -import { - type AdminAuditLog, - createPaginationMeta, - parsePaginationParams, - toAdminAuditLog, -} from '@/app/api/v1/admin/types' +import { type AdminAuditLog, createPaginationMeta, toAdminAuditLog } from '@/app/api/v1/admin/types' import { buildFilterConditions } from '@/app/api/v1/audit-logs/query' const logger = createLogger('AdminAuditLogsAPI') export const GET = withRouteHandler( withAdminAuth(async (request) => { - const url = new URL(request.url) - const { limit, offset } = parsePaginationParams(url) - const parsed = await parseRequest( v1AdminListAuditLogsContract, request, @@ -56,6 +48,7 @@ export const GET = withRouteHandler( try { const query = parsed.data.query + const { limit, offset } = query const conditions = buildFilterConditions({ action: query.action, resourceType: query.resourceType, diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts index 69b773accf5..18bc485fbe6 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts @@ -29,6 +29,7 @@ import { isBillingEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { + adminInvalidJsonResponse, adminValidationErrorResponse, badRequestResponse, internalErrorResponse, @@ -152,7 +153,7 @@ export const PATCH = withRouteHandler( { params: routeParams }, { validationErrorResponse: adminValidationErrorResponse, - invalidJson: 'throw', + invalidJsonResponse: adminInvalidJsonResponse, } ) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts index 0f25618fc2c..1c0913cf576 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts @@ -44,6 +44,7 @@ import { isBillingEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { + adminInvalidJsonResponse, adminValidationErrorResponse, badRequestResponse, internalErrorResponse, @@ -143,7 +144,7 @@ export const PATCH = withRouteHandler( { params: routeParams }, { validationErrorResponse: adminValidationErrorResponse, - invalidJson: 'throw', + invalidJsonResponse: adminInvalidJsonResponse, } ) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts b/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts index 2b00537059a..bfe0bb747c7 100644 --- a/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts +++ b/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts @@ -71,7 +71,10 @@ export const POST = withRouteHandler( }) } catch (error) { logger.error('Failed to requeue outbox event', { eventId: id, error: toError(error).message }) - return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + return NextResponse.json( + { success: false, error: 'Failed to requeue outbox event' }, + { status: 500 } + ) } }) ) diff --git a/apps/sim/app/api/v1/admin/outbox/route.ts b/apps/sim/app/api/v1/admin/outbox/route.ts index f88ac55536c..57ce53c49f5 100644 --- a/apps/sim/app/api/v1/admin/outbox/route.ts +++ b/apps/sim/app/api/v1/admin/outbox/route.ts @@ -77,7 +77,10 @@ export const GET = withRouteHandler( }) } catch (error) { logger.error('Failed to list outbox events', { error: toError(error).message }) - return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + return NextResponse.json( + { success: false, error: 'Failed to list outbox events' }, + { status: 500 } + ) } }) ) diff --git a/apps/sim/app/api/v1/admin/referral-campaigns/route.ts b/apps/sim/app/api/v1/admin/referral-campaigns/route.ts index b7f7c162118..1432b46d37b 100644 --- a/apps/sim/app/api/v1/admin/referral-campaigns/route.ts +++ b/apps/sim/app/api/v1/admin/referral-campaigns/route.ts @@ -41,6 +41,7 @@ import { requireStripeClient } from '@/lib/billing/stripe-client' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { + adminInvalidJsonResponse, adminValidationErrorResponse, badRequestResponse, internalErrorResponse, @@ -181,7 +182,7 @@ export const POST = withRouteHandler( {}, { validationErrorResponse: adminValidationErrorResponse, - invalidJson: 'throw', + invalidJsonResponse: adminInvalidJsonResponse, } ) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/v1/audit-logs/auth.ts b/apps/sim/app/api/v1/audit-logs/auth.ts index b01f6af9736..9adc8d5c29f 100644 --- a/apps/sim/app/api/v1/audit-logs/auth.ts +++ b/apps/sim/app/api/v1/audit-logs/auth.ts @@ -35,7 +35,21 @@ type AuthResult = * Returns the organization ID and all member user IDs on success, * or an error response on failure. */ -export async function validateEnterpriseAuditAccess(userId: string): Promise { +/** + * Structured enterprise audit-access result shared by the v1 and v2 surfaces so + * each version can render the failure in its own response envelope. + */ +export type EnterpriseAuditAccessResult = + | { success: true; context: EnterpriseAuditContext } + | { success: false; status: number; message: string } + +/** + * Core enterprise audit-access check (no response rendering). See + * {@link validateEnterpriseAuditAccess} for the policy checks performed. + */ +export async function resolveEnterpriseAuditAccess( + userId: string +): Promise { const [membership] = await db .select({ organizationId: member.organizationId, role: member.role }) .from(member) @@ -43,31 +57,16 @@ export async function validateEnterpriseAuditAccess(userId: string): Promise m.userId) @@ -108,9 +101,26 @@ export async function validateEnterpriseAuditAccess(userId: string): Promise { + const result = await resolveEnterpriseAuditAccess(userId) + if (result.success) return { success: true, context: result.context } + return { + success: false, + response: NextResponse.json({ error: result.message }, { status: result.status }), } } diff --git a/apps/sim/app/api/v1/logs/filters.ts b/apps/sim/app/api/v1/logs/filters.ts index 0e409e4d53f..8e40ca1db51 100644 --- a/apps/sim/app/api/v1/logs/filters.ts +++ b/apps/sim/app/api/v1/logs/filters.ts @@ -1,5 +1,5 @@ import { workflow, workflowExecutionLogs } from '@sim/db/schema' -import { and, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm' +import { and, asc, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm' export interface LogFilters { workspaceId: string @@ -103,8 +103,14 @@ export function buildLogFilters(filters: LogFilters): SQL { return conditions.length > 0 ? and(...conditions)! : sql`true` } +/** + * Order rows by `(startedAt, id)` so the sort matches the keyset cursor's tuple + * comparison in {@link buildLogFilters}. Without the `id` tie-break, rows that + * share a `startedAt` have an arbitrary order and can be skipped or duplicated + * across pages. + */ export function getOrderBy(order: 'desc' | 'asc' = 'desc') { return order === 'desc' - ? desc(workflowExecutionLogs.startedAt) - : sql`${workflowExecutionLogs.startedAt} ASC` + ? [desc(workflowExecutionLogs.startedAt), desc(workflowExecutionLogs.id)] + : [asc(workflowExecutionLogs.startedAt), asc(workflowExecutionLogs.id)] } diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts index bd6a2185dd5..74f992fc207 100644 --- a/apps/sim/app/api/v1/logs/route.ts +++ b/apps/sim/app/api/v1/logs/route.ts @@ -124,7 +124,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const logs = await baseQuery .where(conditions) - .orderBy(orderBy) + .orderBy(...orderBy) .limit(params.limit + 1) const hasMore = logs.length > params.limit diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 51d69070f32..d27385305f0 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -157,36 +157,46 @@ export function createRateLimitResponse(result: RateLimitResult): NextResponse { } /** - * Verify that the API key is allowed to access the requested workspace. - * - * Enforces two policies: + * Structured workspace-access failure shared by the v1 and v2 API surfaces so + * each version can render the failure in its own response envelope. + */ +export interface WorkspaceAccessError { + status: number + code: 'FORBIDDEN' + message: string +} + +/** + * Core workspace-scope check (no response rendering). Enforces two policies: * - A workspace-scoped key may only target its own workspace. * - A personal key is rejected when the workspace has disabled personal API * keys (`allowPersonalApiKeys = false`), matching the workflow-execution * surface in `app/api/workflows/middleware.ts`. */ -export async function checkWorkspaceScope( +export async function resolveWorkspaceScope( rateLimit: RateLimitResult, requestedWorkspaceId: string -): Promise { +): Promise { if ( rateLimit.keyType === 'workspace' && rateLimit.workspaceId && rateLimit.workspaceId !== requestedWorkspaceId ) { - return NextResponse.json( - { error: 'API key is not authorized for this workspace' }, - { status: 403 } - ) + return { + status: 403, + code: 'FORBIDDEN', + message: 'API key is not authorized for this workspace', + } } if (rateLimit.keyType === 'personal') { const settings = await getWorkspaceBillingSettings(requestedWorkspaceId) if (!settings?.allowPersonalApiKeys) { - return NextResponse.json( - { error: 'Personal API keys are not allowed for this workspace' }, - { status: 403 } - ) + return { + status: 403, + code: 'FORBIDDEN', + message: 'Personal API keys are not allowed for this workspace', + } } } @@ -194,21 +204,46 @@ export async function checkWorkspaceScope( } /** - * Validates workspace-scoped API key bounds and the user's workspace permission. - * Returns null on success, NextResponse on failure. + * Core workspace-access check (scope + the user's workspace permission level), + * shared by v1 and v2. Returns a structured failure or null on success. */ -export async function validateWorkspaceAccess( +export async function resolveWorkspaceAccess( rateLimit: RateLimitResult, userId: string, workspaceId: string, level: PermissionType = 'read' -): Promise { - const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) +): Promise { + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return scopeError const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (!permissionSatisfies(permission, level)) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + return { status: 403, code: 'FORBIDDEN', message: 'Access denied' } } return null } + +/** + * v1 wrapper: renders {@link resolveWorkspaceScope} as the v1 `{ error }` body. + */ +export async function checkWorkspaceScope( + rateLimit: RateLimitResult, + requestedWorkspaceId: string +): Promise { + const failure = await resolveWorkspaceScope(rateLimit, requestedWorkspaceId) + return failure ? NextResponse.json({ error: failure.message }, { status: failure.status }) : null +} + +/** + * v1 wrapper: renders {@link resolveWorkspaceAccess} as the v1 `{ error }` body. + * Returns null on success, NextResponse on failure. + */ +export async function validateWorkspaceAccess( + rateLimit: RateLimitResult, + userId: string, + workspaceId: string, + level: PermissionType = 'read' +): Promise { + const failure = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, level) + return failure ? NextResponse.json({ error: failure.message }, { status: failure.status }) : null +} diff --git a/apps/sim/app/api/v2/audit-logs/[id]/route.ts b/apps/sim/app/api/v2/audit-logs/[id]/route.ts new file mode 100644 index 00000000000..d1fca3d0aa0 --- /dev/null +++ b/apps/sim/app/api/v2/audit-logs/[id]/route.ts @@ -0,0 +1,76 @@ +import { db } from '@sim/db' +import { auditLog } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { v2GetAuditLogContract } from '@/lib/api/contracts/v2/audit-logs' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' +import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' +import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2AuditLogDetailAPI') + +export const revalidate = 0 + +/** + * GET /api/v2/audit-logs/[id] + * + * Returns a single audit log entry scoped to the authenticated user's + * organization. Org-scoped (not workspace-scoped). Unlike v1, authorization + * (`checkRateLimit` → `validateEnterpriseAuditAccess`) runs BEFORE the untrusted + * param is parsed, fixing the v1 ordering inconsistency. The org-scope predicate + * is folded into the lookup so a non-org log reads as 404 (existence is not + * leaked). + */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'audit-logs') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const authResult = await resolveEnterpriseAuditAccess(userId) + if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) + + const parsed = await parseRequest(v2GetAuditLogContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { organizationId, orgMemberIds } = authResult.context + + const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId) + const scopeCondition = buildOrgScopeCondition({ + organizationId, + orgWorkspaceIds, + orgMemberIds, + includeDeparted: true, + }) + + const [log] = await db + .select() + .from(auditLog) + .where(and(eq(auditLog.id, id), scopeCondition)) + .limit(1) + + if (!log) return v2Error('NOT_FOUND', 'Audit log not found') + + return v2Data(formatAuditLogEntry(log), { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Audit log detail fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/audit-logs/route.ts b/apps/sim/app/api/v2/audit-logs/route.ts new file mode 100644 index 00000000000..c785ccaaede --- /dev/null +++ b/apps/sim/app/api/v2/audit-logs/route.ts @@ -0,0 +1,103 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' +import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' +import { + buildFilterConditions, + buildOrgScopeCondition, + getOrgWorkspaceIds, + queryAuditLogs, +} from '@/app/api/v1/audit-logs/query' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2AuditLogsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/audit-logs + * + * Lists audit logs scoped to the authenticated user's organization. Org-scoped + * (not workspace-scoped): `resolveWorkspaceAccess` is intentionally NOT used — + * access is gated by enterprise org admin/owner membership. Auth ordering + * matches v1: `checkRateLimit` → `validateEnterpriseAuditAccess` run before the + * untrusted query is parsed. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'audit-logs') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const authResult = await resolveEnterpriseAuditAccess(userId) + if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) + + const { organizationId, orgMemberIds } = authResult.context + + const parsed = await parseRequest( + v2ListAuditLogsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const params = parsed.data.query + + if (params.actorId && !orgMemberIds.includes(params.actorId)) { + return v2Error('BAD_REQUEST', 'actorId is not a member of your organization') + } + + const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId) + + if (params.workspaceId && !orgWorkspaceIds.includes(params.workspaceId)) { + return v2Error('BAD_REQUEST', 'workspaceId does not belong to your organization') + } + + const scopeCondition = buildOrgScopeCondition({ + organizationId, + orgWorkspaceIds, + orgMemberIds, + includeDeparted: params.includeDeparted, + }) + const filterConditions = buildFilterConditions({ + action: params.action, + resourceType: params.resourceType, + resourceId: params.resourceId, + workspaceId: params.workspaceId, + actorId: params.actorId, + startDate: params.startDate, + endDate: params.endDate, + }) + + const { data, nextCursor } = await queryAuditLogs( + [scopeCondition, ...filterConditions], + params.limit, + params.cursor + ) + + return v2CursorList(data.map(formatAuditLogEntry), nextCursor ?? null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Audit logs fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts new file mode 100644 index 00000000000..9d2e6d603b9 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -0,0 +1,124 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2DeleteFileContract, v2DownloadFileContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import type { V2ErrorCode } from '@/app/api/v2/lib/response' +import { + rateLimitHeaders, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface FileRouteParams { + params: Promise<{ fileId: string }> +} + +/** + * GET /api/v2/files/[fileId] — Download file content (binary). + * + * The response carries no JSON envelope, so rate-limit state is surfaced via + * `X-RateLimit-*` headers. Errors still render the canonical v2 JSON error body. + * Lookups are workspace-scoped (IDOR-safe): a file in another workspace 404s. + */ +export const GET = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DownloadFileContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const fileRecord = await getWorkspaceFile(workspaceId, fileId) + if (!fileRecord) return v2Error('NOT_FOUND', 'File not found') + + const buffer = await fetchWorkspaceFileBuffer(fileRecord) + + return new Response(new Uint8Array(buffer), { + status: 200, + headers: { + 'Content-Type': fileRecord.type || 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${fileRecord.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(fileRecord.name)}`, + 'Content-Length': String(buffer.length), + ...rateLimitHeaders(rateLimit), + }, + }) + } catch (error) { + logger.error('Error downloading file', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * DELETE /api/v2/files/[fileId] — Archive (soft delete) a file. + * + * Delegates to the shared orchestration, which is workspace-scoped and records + * its own audit entry (the request is forwarded so that entry captures client + * IP / user agent). Orchestration `errorCode`s map to specific v2 codes rather + * than v1's blanket 500. + */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DeleteFileContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performDeleteWorkspaceFileItems({ + workspaceId, + userId, + fileIds: [fileId], + request, + }) + + if (!result.success) { + const code: V2ErrorCode = + result.errorCode === 'not_found' + ? 'NOT_FOUND' + : result.errorCode === 'validation' + ? 'BAD_REQUEST' + : result.errorCode === 'conflict' + ? 'CONFLICT' + : 'INTERNAL_ERROR' + return v2Error(code, result.error || 'Failed to delete file') + } + + logger.info(`Archived file ${fileId} from workspace ${workspaceId}`) + + return v2Data({ id: fileId, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error('Error deleting file', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts new file mode 100644 index 00000000000..dbc6e982068 --- /dev/null +++ b/apps/sim/app/api/v2/files/route.ts @@ -0,0 +1,236 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + type V2File, + v2ListFilesContract, + v2UploadFileContract, +} from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { + isPayloadSizeLimitError, + readFileToBufferWithLimit, + readFormDataWithLimit, +} from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + FileConflictError, + getWorkspaceFile, + listWorkspaceFiles, + uploadWorkspaceFile, +} from '@/lib/uploads/contexts/workspace' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FilesAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const MAX_FILE_SIZE = 100 * 1024 * 1024 +const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 + +interface FileCursor { + uploadedAt: string + id: string +} + +/** Stable keyset ordering: `uploadedAt` ascending, `id` ascending as the tiebreaker. */ +function compareFiles(a: V2File, b: V2File): number { + if (a.uploadedAt !== b.uploadedAt) return a.uploadedAt < b.uploadedAt ? -1 : 1 + if (a.id !== b.id) return a.id < b.id ? -1 : 1 + return 0 +} + +/** + * GET /api/v2/files — List files in a workspace with cursor pagination. + * + * The shared {@link listWorkspaceFiles} manager returns the full active set + * ordered by `uploadedAt`; v2 applies a bounded keyset slice over that result in + * the route. Pushing `limit`/`cursor` down into the manager query is a follow-up. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2ListFilesContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, limit, cursor } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const files = await listWorkspaceFiles(workspaceId) + + const items: V2File[] = files + .map((f) => ({ + id: f.id, + name: f.name, + size: f.size, + type: f.type, + key: f.key, + uploadedBy: f.uploadedBy, + uploadedAt: + f.uploadedAt instanceof Date ? f.uploadedAt.toISOString() : String(f.uploadedAt), + })) + .sort(compareFiles) + + const decoded = cursor ? decodeCursor(cursor) : null + const afterCursor = decoded + ? items.filter( + (f) => + f.uploadedAt > decoded.uploadedAt || + (f.uploadedAt === decoded.uploadedAt && f.id > decoded.id) + ) + : items + + const hasMore = afterCursor.length > limit + const page = afterCursor.slice(0, limit) + const last = page.at(-1) + const nextCursor = + hasMore && last ? encodeCursor({ uploadedAt: last.uploadedAt, id: last.id }) : null + + return v2CursorList(page, nextCursor, { rateLimit }) + } catch (error) { + logger.error('Error listing files', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * POST /api/v2/files — Upload a file to a workspace. + * + * Authorization runs fully (rate limit → workspace write access) before the + * multipart body is buffered: the workspace is a contract-validated query param, + * so an unauthorized caller never streams a 100 MB body into memory. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2UploadFileContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + let formData: FormData + try { + formData = await readFormDataWithLimit(request, { + maxBytes: MAX_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES, + label: 'workspace file upload body', + }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return v2Error('PAYLOAD_TOO_LARGE', error.message) + } + return v2Error('BAD_REQUEST', 'Request body must be valid multipart form data') + } + + const rawFile = formData.get('file') + const file = rawFile instanceof File ? rawFile : null + if (!file) { + return v2Error('BAD_REQUEST', 'file form field is required') + } + + if (file.size > MAX_FILE_SIZE) { + return v2Error( + 'PAYLOAD_TOO_LARGE', + `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)` + ) + } + + const buffer = await readFileToBufferWithLimit(file, { + maxBytes: MAX_FILE_SIZE, + label: 'workspace upload file', + }) + + const userFile = await uploadWorkspaceFile( + workspaceId, + userId, + buffer, + file.name, + file.type || 'application/octet-stream' + ) + + logger.info(`Uploaded file: ${file.name} to workspace ${workspaceId}`) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.FILE_UPLOADED, + resourceType: AuditResourceType.FILE, + resourceId: userFile.id, + resourceName: file.name, + description: `Uploaded file "${file.name}" via API`, + metadata: { fileSize: file.size, fileType: file.type || 'application/octet-stream' }, + request, + }) + + const fileRecord = await getWorkspaceFile(workspaceId, userFile.id) + const uploadedAt = + fileRecord?.uploadedAt instanceof Date + ? fileRecord.uploadedAt.toISOString() + : fileRecord?.uploadedAt + ? String(fileRecord.uploadedAt) + : new Date().toISOString() + + const responseFile: V2File = { + id: userFile.id, + name: userFile.name, + size: userFile.size, + type: userFile.type, + key: userFile.key, + uploadedBy: userId, + uploadedAt, + } + + return v2Data(responseFile, { rateLimit, status: 201 }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return v2Error('PAYLOAD_TOO_LARGE', error.message) + } + + const message = getErrorMessage(error, 'Failed to upload file') + if (error instanceof FileConflictError || message.includes('already exists')) { + return v2Error('CONFLICT', message) + } + if (message.includes('Storage limit') || message.includes('storage limit')) { + return v2Error('PAYLOAD_TOO_LARGE', 'Storage limit exceeded') + } + + logger.error('Error uploading file', { error: message }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts new file mode 100644 index 00000000000..235c80707eb --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts @@ -0,0 +1,209 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { document, knowledgeConnector } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq, isNull } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { + type V2KnowledgeDocument, + v2DeleteKnowledgeDocumentContract, + v2GetKnowledgeDocumentContract, +} from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { deleteDocument } from '@/lib/knowledge/documents/service' +import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' +import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeDocumentDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface DocumentDetailRouteParams { + params: Promise<{ id: string; documentId: string }> +} + +/** + * Resolves a knowledge base via the shared v1 ownership invariant + * ({@link resolveKnowledgeBase}) and renders any failure in the v2 envelope. A + * `404` is always `NOT_FOUND`; a `403` is masked as `NOT_FOUND` on reads and + * surfaced as `FORBIDDEN` on writes. + */ +async function resolveKnowledgeBaseScoped( + id: string, + workspaceId: string, + userId: string, + rateLimit: RateLimitResult, + level: 'read' | 'write' +): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> { + const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, level) + if (!(result instanceof NextResponse)) return result + if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found') + return level === 'read' + ? v2Error('NOT_FOUND', 'Knowledge base not found') + : v2Error('FORBIDDEN', 'Access denied') +} + +/** GET /api/v2/knowledge/[id]/documents/[documentId] — Get document details. */ +export const GET = withRouteHandler( + async (request: NextRequest, context: DocumentDetailRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2GetKnowledgeDocumentContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id: knowledgeBaseId, documentId } = parsed.data.params + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + parsed.data.query.workspaceId, + userId, + rateLimit, + 'read' + ) + if (result instanceof NextResponse) return result + + const docs = await db + .select({ + id: document.id, + knowledgeBaseId: document.knowledgeBaseId, + filename: document.filename, + fileSize: document.fileSize, + mimeType: document.mimeType, + processingStatus: document.processingStatus, + processingError: document.processingError, + processingStartedAt: document.processingStartedAt, + processingCompletedAt: document.processingCompletedAt, + chunkCount: document.chunkCount, + tokenCount: document.tokenCount, + characterCount: document.characterCount, + enabled: document.enabled, + uploadedAt: document.uploadedAt, + connectorId: document.connectorId, + connectorType: knowledgeConnector.connectorType, + sourceUrl: document.sourceUrl, + }) + .from(document) + .leftJoin(knowledgeConnector, eq(document.connectorId, knowledgeConnector.id)) + .where( + and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .limit(1) + + const doc = docs[0] + if (!doc) return v2Error('NOT_FOUND', 'Document not found') + + const documentDetail: V2KnowledgeDocument = { + id: doc.id, + knowledgeBaseId: doc.knowledgeBaseId, + filename: doc.filename, + fileSize: doc.fileSize, + mimeType: doc.mimeType, + processingStatus: doc.processingStatus as V2KnowledgeDocument['processingStatus'], + processingError: doc.processingError, + processingStartedAt: serializeDate(doc.processingStartedAt), + processingCompletedAt: serializeDate(doc.processingCompletedAt), + chunkCount: doc.chunkCount, + tokenCount: doc.tokenCount, + characterCount: doc.characterCount, + enabled: doc.enabled, + connectorId: doc.connectorId, + connectorType: doc.connectorType ?? null, + sourceUrl: doc.sourceUrl, + createdAt: serializeDate(doc.uploadedAt), + } + + return v2Data({ document: documentDetail }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error getting document`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +/** DELETE /api/v2/knowledge/[id]/documents/[documentId] — Delete a document. */ +export const DELETE = withRouteHandler( + async (request: NextRequest, context: DocumentDetailRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DeleteKnowledgeDocumentContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id: knowledgeBaseId, documentId } = parsed.data.params + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + parsed.data.query.workspaceId, + userId, + rateLimit, + 'write' + ) + if (result instanceof NextResponse) return result + + const docs = await db + .select({ id: document.id, filename: document.filename }) + .from(document) + .where( + and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .limit(1) + + const doc = docs[0] + if (!doc) return v2Error('NOT_FOUND', 'Document not found') + + await deleteDocument(documentId, requestId) + + recordAudit({ + workspaceId: parsed.data.query.workspaceId, + actorId: userId, + action: AuditAction.DOCUMENT_DELETED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: documentId, + resourceName: doc.filename, + description: `Deleted document "${doc.filename}" from knowledge base via API`, + metadata: { knowledgeBaseId }, + request, + }) + + return v2Data({ id: documentId, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting document`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts new file mode 100644 index 00000000000..9f2c7b5367a --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -0,0 +1,306 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + type V2KnowledgeDocumentSummary, + v2ListKnowledgeDocumentsContract, + v2UploadKnowledgeDocumentContract, +} from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' +import { generateRequestId } from '@/lib/core/utils/request' +import { + isPayloadSizeLimitError, + readFileToBufferWithLimit, + readFormDataWithLimit, +} from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createSingleDocument, + type DocumentData, + getDocuments, + processDocumentsWithQueue, +} from '@/lib/knowledge/documents/service' +import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' +import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { validateFileType } from '@/lib/uploads/utils/validation' +import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' +import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeDocumentsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const MAX_FILE_SIZE = 100 * 1024 * 1024 +const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 + +interface DocumentsRouteParams { + params: Promise<{ id: string }> +} + +/** + * Resolves a knowledge base via the shared v1 ownership invariant + * ({@link resolveKnowledgeBase}) and renders any failure in the v2 envelope. A + * `404` is always `NOT_FOUND`; a `403` is masked as `NOT_FOUND` on reads and + * surfaced as `FORBIDDEN` on writes. + */ +async function resolveKnowledgeBaseScoped( + id: string, + workspaceId: string, + userId: string, + rateLimit: RateLimitResult, + level: 'read' | 'write' +): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> { + const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, level) + if (!(result instanceof NextResponse)) return result + if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found') + return level === 'read' + ? v2Error('NOT_FOUND', 'Knowledge base not found') + : v2Error('FORBIDDEN', 'Access denied') +} + +/** GET /api/v2/knowledge/[id]/documents — List documents in a knowledge base. */ +export const GET = withRouteHandler(async (request: NextRequest, context: DocumentsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2ListKnowledgeDocumentsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { workspaceId, limit, cursor, search, enabledFilter, sortBy, sortOrder } = + parsed.data.query + const { id: knowledgeBaseId } = parsed.data.params + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + workspaceId, + userId, + rateLimit, + 'read' + ) + if (result instanceof NextResponse) return result + + // Opaque cursor encodes the underlying offset (upgradeable to keyset later). + const offset = cursor ? (decodeCursor<{ offset: number }>(cursor)?.offset ?? 0) : 0 + + const documentsResult = await getDocuments( + knowledgeBaseId, + { + enabledFilter: enabledFilter === 'all' ? undefined : enabledFilter, + search, + limit, + offset, + sortBy: sortBy as DocumentSortField, + sortOrder: sortOrder as SortOrder, + }, + requestId + ) + + const documents: V2KnowledgeDocumentSummary[] = documentsResult.documents.map((doc) => ({ + id: doc.id, + knowledgeBaseId, + filename: doc.filename, + fileSize: doc.fileSize, + mimeType: doc.mimeType, + processingStatus: doc.processingStatus, + chunkCount: doc.chunkCount, + tokenCount: doc.tokenCount, + characterCount: doc.characterCount, + enabled: doc.enabled, + createdAt: serializeDate(doc.uploadedAt), + })) + + const nextCursor = documentsResult.pagination.hasMore + ? encodeCursor({ offset: offset + limit }) + : null + return v2CursorList(documents, nextCursor, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing documents`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * POST /api/v2/knowledge/[id]/documents — Upload a document to a knowledge base. + * + * Authorization runs fully before the multipart body is buffered: the workspace + * is a contract-validated query param (not a form field as in v1), so an + * unauthorized caller never streams a file into memory. Order: rate limit → + * KB ownership (write) → usage gate → buffered multipart read. + */ +export const POST = withRouteHandler( + async (request: NextRequest, context: DocumentsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2UploadKnowledgeDocumentContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id: knowledgeBaseId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + workspaceId, + userId, + rateLimit, + 'write' + ) + if (result instanceof NextResponse) return result + + // Fast usage gate before the storage write + indexing (the async backstop + // in processDocumentAsync still covers non-HTTP paths). + const usage = await checkActorUsageLimits(userId, workspaceId) + if (usage.isExceeded) { + return v2Error( + 'USAGE_LIMIT_EXCEEDED', + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' + ) + } + + let formData: FormData + try { + formData = await readFormDataWithLimit(request, { + maxBytes: MAX_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES, + label: 'knowledge document upload body', + }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return v2Error('PAYLOAD_TOO_LARGE', error.message) + } + return v2Error('BAD_REQUEST', 'Request body must be valid multipart form data') + } + + const rawFile = formData.get('file') + const file = rawFile instanceof File ? rawFile : null + if (!file) { + return v2Error('BAD_REQUEST', 'file form field is required') + } + + if (file.size > MAX_FILE_SIZE) { + return v2Error( + 'PAYLOAD_TOO_LARGE', + `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)` + ) + } + + const fileTypeError = validateFileType(file.name, file.type || '') + if (fileTypeError) { + return v2Error('UNSUPPORTED_MEDIA_TYPE', fileTypeError.message) + } + + const buffer = await readFileToBufferWithLimit(file, { + maxBytes: MAX_FILE_SIZE, + label: 'knowledge document file', + }) + const contentType = file.type || 'application/octet-stream' + + const uploadedFile = await uploadWorkspaceFile( + workspaceId, + userId, + buffer, + file.name, + contentType + ) + + const newDocument = await createSingleDocument( + { + filename: file.name, + fileUrl: uploadedFile.url, + fileSize: file.size, + mimeType: contentType, + }, + knowledgeBaseId, + requestId, + userId + ) + + const documentData: DocumentData = { + documentId: newDocument.id, + filename: file.name, + fileUrl: uploadedFile.url, + fileSize: file.size, + mimeType: contentType, + } + + processDocumentsWithQueue([documentData], knowledgeBaseId, {}, requestId).catch(() => { + // Processing errors are logged internally by the queue. + }) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.DOCUMENT_UPLOADED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: newDocument.id, + resourceName: file.name, + description: `Uploaded document "${file.name}" to knowledge base via API`, + metadata: { knowledgeBaseId, fileSize: file.size, mimeType: contentType }, + request, + }) + + const document: V2KnowledgeDocumentSummary = { + id: newDocument.id, + knowledgeBaseId, + filename: newDocument.filename, + fileSize: newDocument.fileSize, + mimeType: newDocument.mimeType, + processingStatus: 'pending', + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + enabled: newDocument.enabled, + createdAt: serializeDate(newDocument.uploadedAt), + } + + return v2Data({ document }, { rateLimit, status: 201 }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return v2Error('PAYLOAD_TOO_LARGE', error.message) + } + + if (error instanceof Error) { + if ( + error.message.includes('Storage limit exceeded') || + error.message.includes('storage limit') + ) { + return v2Error('PAYLOAD_TOO_LARGE', 'Storage limit exceeded') + } + if (error.message.includes('already exists')) { + return v2Error('CONFLICT', 'Resource already exists') + } + } + + logger.error(`[${requestId}] Error uploading document`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/knowledge/[id]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/route.ts new file mode 100644 index 00000000000..79bb1b4b86c --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/route.ts @@ -0,0 +1,193 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + v2DeleteKnowledgeBaseContract, + v2GetKnowledgeBaseContract, + v2UpdateKnowledgeBaseContract, +} from '@/lib/api/contracts/v2/knowledge' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { deleteKnowledgeBase, updateKnowledgeBase } from '@/lib/knowledge/service' +import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { formatKnowledgeBase, resolveKnowledgeBase } from '@/app/api/v1/knowledge/utils' +import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface KnowledgeRouteParams { + params: Promise<{ id: string }> +} + +/** + * Resolves a knowledge base via the shared v1 ownership invariant + * ({@link resolveKnowledgeBase}: workspace access + KB-belongs-to-workspace) and + * renders any failure in the v2 envelope. A `404` (missing KB or workspace + * mismatch) is always `NOT_FOUND`; a `403` (no workspace access) is masked as + * `NOT_FOUND` on reads so cross-workspace KB existence never leaks, and surfaced + * as `FORBIDDEN` on writes. + */ +async function resolveKnowledgeBaseScoped( + id: string, + workspaceId: string, + userId: string, + rateLimit: RateLimitResult, + level: 'read' | 'write' +): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> { + const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, level) + if (!(result instanceof NextResponse)) return result + if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found') + return level === 'read' + ? v2Error('NOT_FOUND', 'Knowledge base not found') + : v2Error('FORBIDDEN', 'Access denied') +} + +/** GET /api/v2/knowledge/[id] — Get knowledge base details. */ +export const GET = withRouteHandler(async (request: NextRequest, context: KnowledgeRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2GetKnowledgeBaseContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const result = await resolveKnowledgeBaseScoped( + id, + parsed.data.query.workspaceId, + userId, + rateLimit, + 'read' + ) + if (result instanceof NextResponse) return result + + return v2Data({ knowledgeBase: formatKnowledgeBase(result.kb) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error getting knowledge base`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PUT /api/v2/knowledge/[id] — Update a knowledge base. */ +export const PUT = withRouteHandler(async (request: NextRequest, context: KnowledgeRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2UpdateKnowledgeBaseContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, name, description, chunkingConfig } = parsed.data.body + + const result = await resolveKnowledgeBaseScoped(id, workspaceId, userId, rateLimit, 'write') + if (result instanceof NextResponse) return result + + const updates: { + name?: string + description?: string + chunkingConfig?: { maxSize: number; minSize: number; overlap: number } + } = {} + if (name !== undefined) updates.name = name + if (description !== undefined) updates.description = description + if (chunkingConfig !== undefined) updates.chunkingConfig = chunkingConfig + + const updatedKb = await updateKnowledgeBase(id, updates, requestId) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: id, + resourceName: updatedKb.name, + description: `Updated knowledge base "${updatedKb.name}" via API`, + metadata: { updatedFields: Object.keys(updates) }, + request, + }) + + return v2Data({ knowledgeBase: formatKnowledgeBase(updatedKb) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + if (error instanceof Error) { + if (error.message.includes('does not have permission')) { + return v2Error('FORBIDDEN', 'Access denied') + } + if (error.message.includes('already exists')) { + return v2Error('CONFLICT', 'Resource already exists') + } + } + + logger.error(`[${requestId}] Error updating knowledge base`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/knowledge/[id] — Delete a knowledge base. */ +export const DELETE = withRouteHandler( + async (request: NextRequest, context: KnowledgeRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DeleteKnowledgeBaseContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const result = await resolveKnowledgeBaseScoped( + id, + parsed.data.query.workspaceId, + userId, + rateLimit, + 'write' + ) + if (result instanceof NextResponse) return result + + await deleteKnowledgeBase(id, requestId) + + recordAudit({ + workspaceId: parsed.data.query.workspaceId, + actorId: userId, + action: AuditAction.KNOWLEDGE_BASE_DELETED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: id, + resourceName: result.kb.name, + description: `Deleted knowledge base "${result.kb.name}" via API`, + request, + }) + + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting knowledge base`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/knowledge/route.ts b/apps/sim/app/api/v2/knowledge/route.ts new file mode 100644 index 00000000000..d1fb7d5b10d --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -0,0 +1,140 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateKnowledgeBaseContract, + v2ListKnowledgeBasesContract, +} from '@/lib/api/contracts/v2/knowledge' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings' +import { createKnowledgeBase, getKnowledgeBases } from '@/lib/knowledge/service' +import { formatKnowledgeBase } from '@/app/api/v1/knowledge/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/knowledge — List knowledge bases in a workspace. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2ListKnowledgeBasesContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const knowledgeBases = await getKnowledgeBases(userId, workspaceId) + const items = knowledgeBases.map(formatKnowledgeBase) + + // `getKnowledgeBases` returns the full bounded workspace set → single page. + return v2CursorList(items, null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing knowledge bases`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/knowledge — Create a new knowledge base. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2CreateKnowledgeBaseContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, name, description, chunkingConfig } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const kb = await createKnowledgeBase( + { + name, + description, + workspaceId, + userId, + embeddingModel: getConfiguredEmbeddingModel(), + embeddingDimension: EMBEDDING_DIMENSIONS, + chunkingConfig: chunkingConfig ?? { maxSize: 1024, minSize: 100, overlap: 200 }, + }, + requestId + ) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.KNOWLEDGE_BASE_CREATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: kb.id, + resourceName: kb.name, + description: `Created knowledge base "${kb.name}" via API`, + metadata: { chunkingConfig }, + request, + }) + + return v2Data({ knowledgeBase: formatKnowledgeBase(kb) }, { rateLimit, status: 201 }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + if (error instanceof Error) { + if (error.message.includes('does not have permission')) { + return v2Error('FORBIDDEN', 'Access denied') + } + if ( + error.message.includes('Storage limit exceeded') || + error.message.includes('storage limit') + ) { + return v2Error('PAYLOAD_TOO_LARGE', 'Storage limit exceeded') + } + if (error.message.includes('already exists')) { + return v2Error('CONFLICT', 'Resource already exists') + } + } + + logger.error(`[${requestId}] Error creating knowledge base`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts new file mode 100644 index 00000000000..8f432bf467e --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -0,0 +1,299 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + type V2KnowledgeSearchResult, + v2SearchKnowledgeContract, +} from '@/lib/api/contracts/v2/knowledge' +import { isZodError, parseRequest } from '@/lib/api/server' +import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' +import { recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' +import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' +import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' +import type { StructuredFilter } from '@/lib/knowledge/types' +import { + generateSearchEmbedding, + getDocumentMetadataByIds, + getQueryStrategy, + handleTagAndVectorSearch, + handleTagOnlySearch, + handleVectorOnlySearch, + type SearchResult, +} from '@/app/api/knowledge/search/utils' +import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeSearchAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** POST /api/v2/knowledge/search — Vector / tag search across knowledge bases. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-search') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2SearchKnowledgeContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, topK, query, tagFilters } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + // A query incurs hosted embedding (+ optional rerank) cost — gate the actor's + // usage and frozen status before spending. Tag-only search is free, so skip it. + if (query && query.trim().length > 0) { + const usage = await checkActorUsageLimits(userId, workspaceId) + if (usage.isExceeded) { + return v2Error( + 'USAGE_LIMIT_EXCEEDED', + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' + ) + } + } + + const knowledgeBaseIds = Array.isArray(parsed.data.body.knowledgeBaseIds) + ? parsed.data.body.knowledgeBaseIds + : [parsed.data.body.knowledgeBaseIds] + + const accessChecks = await Promise.all( + knowledgeBaseIds.map((kbId) => checkKnowledgeBaseAccess(kbId, userId)) + ) + const accessibleKbs = accessChecks + .filter( + (ac): ac is KnowledgeBaseAccessResult => + ac.hasAccess === true && ac.knowledgeBase.workspaceId === workspaceId + ) + .map((ac) => ac.knowledgeBase) + const accessibleKbIds = accessibleKbs.map((kb) => kb.id) + + if (accessibleKbIds.length === 0) { + return v2Error('NOT_FOUND', 'Knowledge base not found or access denied') + } + + const inaccessibleKbIds = knowledgeBaseIds.filter((id) => !accessibleKbIds.includes(id)) + if (inaccessibleKbIds.length > 0) { + return v2Error( + 'NOT_FOUND', + `Knowledge bases not found or access denied: ${inaccessibleKbIds.join(', ')}` + ) + } + + let structuredFilters: StructuredFilter[] = [] + const tagDefsCache = new Map>>() + + if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 1) { + return v2Error( + 'BAD_REQUEST', + 'Tag filters are only supported when searching a single knowledge base' + ) + } + + if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 0) { + const kbId = accessibleKbIds[0] + const tagDefs = await getDocumentTagDefinitions(kbId) + tagDefsCache.set(kbId, tagDefs) + + const displayNameToTagDef: Record = {} + tagDefs.forEach((def) => { + displayNameToTagDef[def.displayName] = { + tagSlot: def.tagSlot, + fieldType: def.fieldType, + } + }) + + const undefinedTags: string[] = [] + const typeErrors: string[] = [] + + for (const filter of tagFilters) { + const tagDef = displayNameToTagDef[filter.tagName] + if (!tagDef) { + undefinedTags.push(filter.tagName) + continue + } + const validationError = validateTagValue( + filter.tagName, + String(filter.value), + tagDef.fieldType + ) + if (validationError) { + typeErrors.push(validationError) + } + } + + if (undefinedTags.length > 0 || typeErrors.length > 0) { + const errorParts: string[] = [] + if (undefinedTags.length > 0) { + errorParts.push(buildUndefinedTagsError(undefinedTags)) + } + if (typeErrors.length > 0) { + errorParts.push(...typeErrors) + } + return v2Error('BAD_REQUEST', errorParts.join('\n')) + } + + structuredFilters = tagFilters.map((filter) => { + const tagDef = displayNameToTagDef[filter.tagName]! + return { + tagSlot: tagDef.tagSlot, + fieldType: tagDef.fieldType, + operator: filter.operator, + value: filter.value, + valueTo: filter.valueTo, + } + }) + } + + const hasQuery = Boolean(query && query.trim().length > 0) + const hasFilters = structuredFilters.length > 0 + + const embeddingModels = Array.from(new Set(accessibleKbs.map((kb) => kb.embeddingModel))) + if (hasQuery && embeddingModels.length > 1) { + return v2Error( + 'BAD_REQUEST', + 'Selected knowledge bases use different embedding models and cannot be searched together. Search them separately.' + ) + } + const queryEmbeddingModel = embeddingModels[0] + + let results: SearchResult[] + let queryEmbeddingIsBYOK: boolean | null = null + + if (!hasQuery && hasFilters) { + results = await handleTagOnlySearch({ + knowledgeBaseIds: accessibleKbIds, + topK, + structuredFilters, + }) + } else if (hasQuery && hasFilters) { + const strategy = getQueryStrategy(accessibleKbIds.length, topK) + const queryEmbeddingResult = await generateSearchEmbedding( + query!, + queryEmbeddingModel, + workspaceId + ) + queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK + const queryVector = JSON.stringify(queryEmbeddingResult.embedding) + results = await handleTagAndVectorSearch({ + knowledgeBaseIds: accessibleKbIds, + topK, + structuredFilters, + queryVector, + distanceThreshold: strategy.distanceThreshold, + }) + } else if (hasQuery) { + const strategy = getQueryStrategy(accessibleKbIds.length, topK) + const queryEmbeddingResult = await generateSearchEmbedding( + query!, + queryEmbeddingModel, + workspaceId + ) + queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK + const queryVector = JSON.stringify(queryEmbeddingResult.embedding) + results = await handleVectorOnlySearch({ + knowledgeBaseIds: accessibleKbIds, + topK, + queryVector, + distanceThreshold: strategy.distanceThreshold, + }) + } else { + return v2Error('BAD_REQUEST', 'Either query or tagFilters must be provided') + } + + if (queryEmbeddingIsBYOK !== null) { + await recordSearchEmbeddingUsage({ + userId, + workspaceId, + embeddingModel: queryEmbeddingModel, + query: query!, + isBYOK: queryEmbeddingIsBYOK, + sourceReference: `v2-kb-search:${requestId}`, + }) + } + + const tagDefsResults = await Promise.all( + accessibleKbIds.map(async (kbId) => { + try { + const tagDefs = tagDefsCache.get(kbId) ?? (await getDocumentTagDefinitions(kbId)) + const map: Record = {} + tagDefs.forEach((def) => { + map[def.tagSlot] = def.displayName + }) + return { kbId, map } + } catch { + return { kbId, map: {} as Record } + } + }) + ) + const tagDefinitionsMap: Record> = {} + tagDefsResults.forEach(({ kbId, map }) => { + tagDefinitionsMap[kbId] = map + }) + + const documentIds = results.map((r) => r.documentId) + const documentMetadataMap = await getDocumentMetadataByIds(documentIds) + + const searchResults: V2KnowledgeSearchResult[] = results.map((result) => { + const kbTagMap = tagDefinitionsMap[result.knowledgeBaseId] || {} + const metadata: Record = {} + + ALL_TAG_SLOTS.forEach((slot) => { + const tagValue = result[slot as keyof SearchResult] + if (tagValue !== null && tagValue !== undefined) { + const displayName = kbTagMap[slot] || slot + metadata[displayName] = tagValue + } + }) + + const docMeta = documentMetadataMap[result.documentId] + return { + documentId: result.documentId, + documentName: docMeta?.filename ?? null, + sourceUrl: docMeta?.sourceUrl ?? null, + content: result.content, + chunkIndex: result.chunkIndex, + metadata, + similarity: hasQuery ? 1 - result.distance : 1, + } + }) + + return v2Data( + { + results: searchResults, + query: query || '', + knowledgeBaseIds: accessibleKbIds, + topK, + totalResults: results.length, + }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + logger.error(`[${requestId}] Knowledge search error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts new file mode 100644 index 00000000000..e6c5e3dc5d2 --- /dev/null +++ b/apps/sim/app/api/v2/lib/response.ts @@ -0,0 +1,144 @@ +import { NextResponse } from 'next/server' +import type { ZodError } from 'zod' +import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server' +import type { RateLimitResult, WorkspaceAccessError } from '@/app/api/v1/middleware' + +/** + * Runtime response helpers for the v2 API surface. Every v2 route renders its + * output through these so the envelope, error shape, and rate-limit headers stay + * identical across the whole surface. v2 routes reuse the v1 auth/rate-limit + * middleware and the platform domain services — these helpers only standardize + * the HTTP envelope. + */ + +export type V2ErrorCode = + | 'BAD_REQUEST' + | 'UNAUTHORIZED' + | 'FORBIDDEN' + | 'NOT_FOUND' + | 'CONFLICT' + | 'PAYLOAD_TOO_LARGE' + | 'UNSUPPORTED_MEDIA_TYPE' + | 'USAGE_LIMIT_EXCEEDED' + | 'LOCKED' + | 'RATE_LIMITED' + | 'INTERNAL_ERROR' + +const STATUS_BY_CODE: Record = { + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + USAGE_LIMIT_EXCEEDED: 402, + FORBIDDEN: 403, + NOT_FOUND: 404, + CONFLICT: 409, + PAYLOAD_TOO_LARGE: 413, + UNSUPPORTED_MEDIA_TYPE: 415, + LOCKED: 423, + RATE_LIMITED: 429, + INTERNAL_ERROR: 500, +} + +type RateLimitHeaderSource = Pick + +export function rateLimitHeaders(rateLimit?: RateLimitHeaderSource): Record { + if (!rateLimit) return {} + return { + 'X-RateLimit-Limit': rateLimit.limit.toString(), + 'X-RateLimit-Remaining': rateLimit.remaining.toString(), + 'X-RateLimit-Reset': rateLimit.resetAt.toISOString(), + } +} + +interface V2SuccessOptions { + rateLimit?: RateLimitHeaderSource + status?: number + headers?: Record +} + +function successHeaders(options: V2SuccessOptions): Record { + return { ...rateLimitHeaders(options.rateLimit), ...options.headers } +} + +/** `{ data }` (+ rate-limit headers). */ +export function v2Data(data: T, options: V2SuccessOptions = {}): NextResponse { + return NextResponse.json( + { data }, + { status: options.status ?? 200, headers: successHeaders(options) } + ) +} + +/** `{ data, nextCursor }` (+ rate-limit headers). */ +export function v2CursorList( + data: T[], + nextCursor: string | null, + options: V2SuccessOptions = {} +): NextResponse { + return NextResponse.json( + { data, nextCursor }, + { status: options.status ?? 200, headers: successHeaders(options) } + ) +} + +interface V2ErrorOptions { + status?: number + details?: unknown + headers?: Record +} + +/** `{ error: { code, message, details? } }`. */ +export function v2Error( + code: V2ErrorCode, + message: string, + options: V2ErrorOptions = {} +): NextResponse { + const error: { code: V2ErrorCode; message: string; details?: unknown } = { code, message } + if (options.details !== undefined) error.details = options.details + return NextResponse.json( + { error }, + { status: options.status ?? STATUS_BY_CODE[code], headers: options.headers } + ) +} + +/** Render a contract `ZodError` as the v2 error envelope. */ +export function v2ValidationError(error: ZodError): NextResponse { + return v2Error('BAD_REQUEST', getValidationErrorMessage(error, 'Invalid request'), { + details: serializeZodIssues(error), + }) +} + +/** Render a shared {@link WorkspaceAccessError} as the v2 error envelope. */ +export function v2WorkspaceAccessError(failure: WorkspaceAccessError): NextResponse { + return v2Error(failure.code, failure.message, { status: failure.status }) +} + +/** + * Render a v1 rate-limit/auth failure (`checkRateLimit` result) as the v2 error + * envelope: an auth failure becomes 401, a throttle becomes 429 with + * `Retry-After`. + */ +export function v2RateLimitError(rateLimit: RateLimitResult): NextResponse { + const headers = rateLimitHeaders(rateLimit) + if (rateLimit.error) { + return v2Error('UNAUTHORIZED', rateLimit.error, { headers }) + } + const retryAfterSeconds = rateLimit.retryAfterMs + ? Math.ceil(rateLimit.retryAfterMs / 1000) + : Math.ceil((rateLimit.resetAt.getTime() - Date.now()) / 1000) + return v2Error('RATE_LIMITED', 'API rate limit exceeded', { + headers: { ...headers, 'Retry-After': retryAfterSeconds.toString() }, + details: { retryAfter: rateLimit.resetAt.toISOString() }, + }) +} + +/** Opaque base64-JSON keyset cursor codec shared by all v2 cursor lists. */ +export function encodeCursor(data: Record): string { + return Buffer.from(JSON.stringify(data)).toString('base64') +} + +export function decodeCursor>(cursor: string): T | null { + try { + return JSON.parse(Buffer.from(cursor, 'base64').toString()) as T + } catch { + return null + } +} diff --git a/apps/sim/app/api/v2/logs/[id]/route.ts b/apps/sim/app/api/v2/logs/[id]/route.ts new file mode 100644 index 00000000000..698e59f10ed --- /dev/null +++ b/apps/sim/app/api/v2/logs/[id]/route.ts @@ -0,0 +1,109 @@ +import { db } from '@sim/db' +import { workflow, workflowExecutionLogs } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2LogDetail, v2GetLogContract } from '@/lib/api/contracts/v2/logs' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2LogDetailAPI') + +export const revalidate = 0 + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'logs-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2GetLogContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const rows = await db + .select({ + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, + executionId: workflowExecutionLogs.executionId, + level: workflowExecutionLogs.level, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + executionData: workflowExecutionLogs.executionData, + costTotal: workflowExecutionLogs.costTotal, + files: workflowExecutionLogs.files, + createdAt: workflowExecutionLogs.createdAt, + workflowName: workflow.name, + workflowDescription: workflow.description, + workflowFolderId: workflow.folderId, + workflowUserId: workflow.userId, + workflowWorkspaceId: workflow.workspaceId, + workflowCreatedAt: workflow.createdAt, + workflowUpdatedAt: workflow.updatedAt, + }) + .from(workflowExecutionLogs) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .where(eq(workflowExecutionLogs.id, id)) + .limit(1) + + const log = rows[0] + if (!log) return v2Error('NOT_FOUND', 'Log not found') + + // Convert an authorization failure into 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, log.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Log not found') + + const executionData = await materializeExecutionData( + log.executionData as Record | null, + { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } + ) + + const detail: V2LogDetail = { + id: log.id, + workflowId: log.workflowId, + executionId: log.executionId, + level: log.level, + trigger: log.trigger, + startedAt: log.startedAt.toISOString(), + endedAt: log.endedAt ? log.endedAt.toISOString() : null, + totalDurationMs: log.totalDurationMs, + files: (log.files as unknown[] | null) ?? null, + workflow: { + id: log.workflowId, + name: log.workflowName || 'Deleted Workflow', + description: log.workflowDescription, + folderId: log.workflowFolderId, + userId: log.workflowUserId, + workspaceId: log.workflowWorkspaceId, + createdAt: log.workflowCreatedAt ? log.workflowCreatedAt.toISOString() : null, + updatedAt: log.workflowUpdatedAt ? log.workflowUpdatedAt.toISOString() : null, + deleted: !log.workflowName, + }, + executionData, + cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, + createdAt: log.createdAt.toISOString(), + } + + return v2Data(detail, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Log detail fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts b/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts new file mode 100644 index 00000000000..da936577def --- /dev/null +++ b/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts @@ -0,0 +1,74 @@ +import { db } from '@sim/db' +import { workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2Execution, v2GetExecutionContract } from '@/lib/api/contracts/v2/logs' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2ExecutionAPI') + +export const revalidate = 0 + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ executionId: string }> }) => { + try { + const rateLimit = await checkRateLimit(request, 'logs-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2GetExecutionContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { executionId } = parsed.data.params + + const rows = await db + .select() + .from(workflowExecutionLogs) + .where(eq(workflowExecutionLogs.executionId, executionId)) + .limit(1) + + if (rows.length === 0) return v2Error('NOT_FOUND', 'Workflow execution not found') + + const workflowLog = rows[0] + + // Convert an authorization failure into 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, workflowLog.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Workflow execution not found') + + const [snapshot] = await db + .select() + .from(workflowExecutionSnapshots) + .where(eq(workflowExecutionSnapshots.id, workflowLog.stateSnapshotId)) + .limit(1) + + if (!snapshot) return v2Error('NOT_FOUND', 'Workflow state snapshot not found') + + const execution: V2Execution = { + executionId, + workflowId: workflowLog.workflowId, + workflowState: snapshot.stateData, + executionMetadata: { + trigger: workflowLog.trigger, + startedAt: workflowLog.startedAt.toISOString(), + endedAt: workflowLog.endedAt ? workflowLog.endedAt.toISOString() : null, + totalDurationMs: workflowLog.totalDurationMs, + cost: workflowLog.costTotal != null ? { total: Number(workflowLog.costTotal) } : null, + }, + } + + return v2Data(execution, { rateLimit }) + } catch (error) { + logger.error('Error fetching execution data', { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts new file mode 100644 index 00000000000..a4cc3372d37 --- /dev/null +++ b/apps/sim/app/api/v2/logs/route.ts @@ -0,0 +1,168 @@ +import { db } from '@sim/db' +import { workflow, workflowExecutionLogs } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { eq, sql } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2LogListItem, v2ListLogsContract } from '@/lib/api/contracts/v2/logs' +import { parseRequest } from '@/lib/api/server' +import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { buildLogFilters, getOrderBy } from '@/app/api/v1/logs/filters' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2LogsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'logs') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2ListLogsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const params = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const filters = { + workspaceId: params.workspaceId, + workflowIds: params.workflowIds?.split(',').filter(Boolean), + folderIds: params.folderIds?.split(',').filter(Boolean), + triggers: params.triggers?.split(',').filter(Boolean), + level: params.level, + startDate: params.startDate ? new Date(params.startDate) : undefined, + endDate: params.endDate ? new Date(params.endDate) : undefined, + executionId: params.executionId, + minDurationMs: params.minDurationMs, + maxDurationMs: params.maxDurationMs, + minCost: params.minCost, + maxCost: params.maxCost, + model: params.model, + cursor: params.cursor + ? decodeCursor<{ startedAt: string; id: string }>(params.cursor) || undefined + : undefined, + order: params.order, + } + + const conditions = buildLogFilters(filters) + const orderBy = getOrderBy(params.order) + + const rows = await db + .select({ + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, + executionId: workflowExecutionLogs.executionId, + deploymentVersionId: workflowExecutionLogs.deploymentVersionId, + level: workflowExecutionLogs.level, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + costTotal: workflowExecutionLogs.costTotal, + files: workflowExecutionLogs.files, + executionData: params.details === 'full' ? workflowExecutionLogs.executionData : sql`null`, + workflowName: workflow.name, + workflowDescription: workflow.description, + }) + .from(workflowExecutionLogs) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .where(conditions) + .orderBy(...orderBy) + .limit(params.limit + 1) + + const hasMore = rows.length > params.limit + const data = rows.slice(0, params.limit) + + let nextCursor: string | null = null + if (hasMore && data.length > 0) { + const lastLog = data[data.length - 1] + nextCursor = encodeCursor({ startedAt: lastLog.startedAt.toISOString(), id: lastLog.id }) + } + + type LogRow = (typeof data)[number] + const buildItem = (log: LogRow): V2LogListItem => { + const item: V2LogListItem = { + id: log.id, + workflowId: log.workflowId, + executionId: log.executionId, + deploymentVersionId: log.deploymentVersionId, + level: log.level, + trigger: log.trigger, + startedAt: log.startedAt.toISOString(), + endedAt: log.endedAt ? log.endedAt.toISOString() : null, + totalDurationMs: log.totalDurationMs, + cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, + files: (log.files as unknown[] | null) ?? null, + } + if (params.details === 'full') { + item.workflow = { + id: log.workflowId, + name: log.workflowName || 'Deleted Workflow', + description: log.workflowDescription, + deleted: !log.workflowName, + } + } + return item + } + + const needsMaterialize = + params.details === 'full' && (params.includeFinalOutput || params.includeTraceSpans) + + const formattedLogs = needsMaterialize + ? await mapWithConcurrency(data, MATERIALIZE_CONCURRENCY, async (log) => { + const item = buildItem(log) + if (log.executionData) { + const execData = (await materializeExecutionData( + log.executionData as Record | null, + { + workspaceId: log.workspaceId, + workflowId: log.workflowId, + executionId: log.executionId, + } + )) as Record + if (params.includeFinalOutput && execData.finalOutput) { + item.finalOutput = execData.finalOutput + } + if (params.includeTraceSpans && execData.traceSpans) { + item.traceSpans = execData.traceSpans + } + } + return item + }) + : data.map(buildItem) + + return v2CursorList(formattedLogs, nextCursor, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Logs fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts new file mode 100644 index 00000000000..48b6726eb35 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts @@ -0,0 +1,269 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2AddTableColumnContract, + v2DeleteTableColumnContract, + v2UpdateTableColumnContract, +} from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + addTableColumn, + deleteColumn, + renameColumn, + updateColumnConstraints, + updateColumnType, +} from '@/lib/table' +import { checkAccess, normalizeColumn } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { v2TableAccessError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableColumnsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface ColumnsRouteParams { + params: Promise<{ tableId: string }> +} + +/** POST /api/v2/tables/[tableId]/columns — Add a column to the table schema. */ +export const POST = withRouteHandler(async (request: NextRequest, context: ColumnsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-columns') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2AddTableColumnContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const updatedTable = await addTableColumn(tableId, validated.column, requestId) + + recordAudit({ + workspaceId: validated.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Added column "${validated.column.name}" to table "${table.name}"`, + metadata: { column: validated.column }, + request, + }) + + return v2Data({ columns: updatedTable.schema.columns.map(normalizeColumn) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + if (error instanceof Error) { + if (error.message.includes('already exists') || error.message.includes('maximum column')) { + return v2Error('BAD_REQUEST', error.message) + } + if (error.message === 'Table not found') { + return v2Error('NOT_FOUND', error.message) + } + } + + logger.error(`[${requestId}] Error adding column to table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/tables/[tableId]/columns — Update a column (rename, type change, constraints). */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: ColumnsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-columns') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2UpdateTableColumnContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const { updates } = validated + let updatedTable = null + + if (updates.name) { + updatedTable = await renameColumn( + { tableId, oldName: validated.columnName, newName: updates.name }, + requestId + ) + } + + if (updates.type) { + updatedTable = await updateColumnType( + { tableId, columnName: updates.name ?? validated.columnName, newType: updates.type }, + requestId + ) + } + + if (updates.required !== undefined || updates.unique !== undefined) { + updatedTable = await updateColumnConstraints( + { + tableId, + columnName: updates.name ?? validated.columnName, + ...(updates.required !== undefined ? { required: updates.required } : {}), + ...(updates.unique !== undefined ? { unique: updates.unique } : {}), + }, + requestId + ) + } + + if (!updatedTable) { + return v2Error('BAD_REQUEST', 'No updates specified') + } + + recordAudit({ + workspaceId: validated.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Updated column "${validated.columnName}" in table "${table.name}"`, + metadata: { columnName: validated.columnName, updates }, + request, + }) + + return v2Data({ columns: updatedTable.schema.columns.map(normalizeColumn) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + if (error instanceof Error) { + const msg = error.message + if (msg.includes('not found') || msg.includes('Table not found')) { + return v2Error('NOT_FOUND', msg) + } + if ( + msg.includes('already exists') || + msg.includes('Cannot delete the last column') || + msg.includes('Cannot set column') || + msg.includes('Invalid column') || + msg.includes('exceeds maximum') || + msg.includes('incompatible') || + msg.includes('duplicate') + ) { + return v2Error('BAD_REQUEST', msg) + } + } + + logger.error(`[${requestId}] Error updating column in table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/tables/[tableId]/columns — Delete a column from the table schema. */ +export const DELETE = withRouteHandler( + async (request: NextRequest, context: ColumnsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-columns') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DeleteTableColumnContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const updatedTable = await deleteColumn( + { tableId, columnName: validated.columnName }, + requestId + ) + + recordAudit({ + workspaceId: validated.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Deleted column "${validated.columnName}" from table "${table.name}"`, + metadata: { columnName: validated.columnName }, + request, + }) + + return v2Data({ columns: updatedTable.schema.columns.map(normalizeColumn) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + if (error instanceof Error) { + if (error.message.includes('not found') || error.message === 'Table not found') { + return v2Error('NOT_FOUND', error.message) + } + if (error.message.includes('Cannot delete') || error.message.includes('last column')) { + return v2Error('BAD_REQUEST', error.message) + } + } + + logger.error(`[${requestId}] Error deleting column from table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts new file mode 100644 index 00000000000..202ad6a7900 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -0,0 +1,114 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2DeleteTableContract, v2GetTableContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { deleteTable } from '@/lib/table' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiTable, v2TableAccessError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** GET /api/v2/tables/[tableId] — Get table details. */ +export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2GetTableContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!result.ok) return v2Error('NOT_FOUND', 'Table not found') + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + return v2Data({ table: toApiTable(result.table) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error getting table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/tables/[tableId] — Archive a table. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DeleteTableContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + await deleteTable(tableId, requestId) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.TABLE_DELETED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: result.table.name, + description: `Archived table "${result.table.name}"`, + request, + }) + + return v2Data({ id: tableId }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts new file mode 100644 index 00000000000..59861c6c0ea --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts @@ -0,0 +1,226 @@ +import { db } from '@sim/db' +import { userTableRows } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { and, eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { + v2DeleteTableRowContract, + v2GetTableRowContract, + v2UpdateTableRowContract, +} from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { RowData, TableSchema } from '@/lib/table' +import { buildIdByName, buildNameById, rowDataNameToId, updateRow } from '@/lib/table' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiRow, v2TableAccessError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableRowAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RowRouteParams { + params: Promise<{ tableId: string; rowId: string }> +} + +/** GET /api/v2/tables/[tableId]/rows/[rowId] — Get a single row. */ +export const GET = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-row-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2GetTableRowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, rowId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!result.ok) return v2Error('NOT_FOUND', 'Table not found') + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const [row] = await db + .select({ + id: userTableRows.id, + data: userTableRows.data, + position: userTableRows.position, + createdAt: userTableRows.createdAt, + updatedAt: userTableRows.updatedAt, + }) + .from(userTableRows) + .where( + and( + eq(userTableRows.id, rowId), + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId) + ) + ) + .limit(1) + + if (!row) return v2Error('NOT_FOUND', 'Row not found') + + const nameById = buildNameById(result.table.schema as TableSchema) + return v2Data( + { + row: toApiRow( + { + id: row.id, + data: row.data as RowData, + position: row.position, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }, + nameById + ), + }, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error getting row`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/tables/[tableId]/rows/[rowId] — Partial update a single row. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-row-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2UpdateTableRowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, rowId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const idByName = buildIdByName(table.schema as TableSchema) + const nameById = buildNameById(table.schema as TableSchema) + const updatedRow = await updateRow( + { + tableId, + rowId, + data: rowDataNameToId(validated.data as RowData, idByName), + workspaceId: validated.workspaceId, + actorUserId: userId, + }, + table, + requestId + ) + // No `cancellationGuard` is passed, so `updateRow` can't return null here. + // Defensive narrowing for TypeScript. + if (!updatedRow) return v2Error('NOT_FOUND', 'Row not found') + + return v2Data({ row: toApiRow(updatedRow, nameById) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + const errorMessage = toError(error).message + if (errorMessage === 'Row not found') return v2Error('NOT_FOUND', errorMessage) + + if ( + errorMessage.includes('Row size exceeds') || + errorMessage.includes('Schema validation') || + errorMessage.includes('must be unique') || + errorMessage.includes('Unique constraint violation') || + errorMessage.includes('Cannot set unique column') + ) { + return v2Error('BAD_REQUEST', errorMessage) + } + + logger.error(`[${requestId}] Error updating row`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/tables/[tableId]/rows/[rowId] — Delete a single row. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-row-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DeleteTableRowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, rowId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const [deletedRow] = await db + .delete(userTableRows) + .where( + and( + eq(userTableRows.id, rowId), + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId) + ) + ) + .returning({ id: userTableRows.id }) + + if (!deletedRow) return v2Error('NOT_FOUND', 'Row not found') + + // v2 mirrors the bulk delete shape: always returns `deletedRowIds`. + return v2Data({ deletedCount: 1, deletedRowIds: [deletedRow.id] }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting row`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts new file mode 100644 index 00000000000..60957cf8aa9 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts @@ -0,0 +1,406 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest, NextResponse } from 'next/server' +import type { V1BatchInsertTableRowsBody } from '@/lib/api/contracts/v1/tables' +import { + v2CreateTableRowsContract, + v2DeleteTableRowsContract, + v2ListTableRowsContract, + v2UpdateRowsByFilterContract, +} from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { Filter, RowData, TableSchema } from '@/lib/table' +import { + batchInsertRows, + buildIdByName, + buildNameById, + deleteRowsByFilter, + deleteRowsByIds, + filterNamesToIds, + insertRow, + rowDataNameToId, + sortNamesToIds, + updateRowsByFilter, + validateBatchRows, + validateRowData, + validateRowSize, +} from '@/lib/table' +import { queryRows } from '@/lib/table/rows/service' +import { TableQueryValidationError } from '@/lib/table/sql' +import { checkAccess } from '@/app/api/table/utils' +import { + checkRateLimit, + type RateLimitResult, + resolveWorkspaceScope, +} from '@/app/api/v1/middleware' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { + toApiRow, + v2RowValidationError, + v2RowWriteError, + v2TableAccessError, +} from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableRowsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRowsRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * Inserts a validated batch of rows. Authorizes against the table's own + * workspace (IDOR guard) before any write, translates name-keyed row data to + * storage ids, and returns the inserted rows in the canonical v2 envelope. + */ +async function handleBatchInsert( + requestId: string, + tableId: string, + validated: V1BatchInsertTableRowsBody, + userId: string, + rateLimit: RateLimitResult +): Promise { + const accessResult = await checkAccess(tableId, userId, 'write') + if (!accessResult.ok) return v2TableAccessError(accessResult) + + const { table } = accessResult + if (validated.workspaceId !== table.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + // External callers key row data by column name; storage keys by id. + const idByName = buildIdByName(table.schema as TableSchema) + const nameById = buildNameById(table.schema as TableSchema) + const rows = (validated.rows as RowData[]).map((r) => rowDataNameToId(r, idByName)) + + const validation = await validateBatchRows({ + rows, + schema: table.schema as TableSchema, + tableId, + }) + if (!validation.valid) return v2RowValidationError(validation.response) + + try { + const insertedRows = await batchInsertRows( + { tableId, rows, workspaceId: validated.workspaceId, userId }, + table, + requestId + ) + + return v2Data( + { + rows: insertedRows.map((r) => toApiRow(r, nameById)), + insertedCount: insertedRows.length, + }, + { rateLimit } + ) + } catch (error) { + const response = v2RowWriteError(error) + if (response) return response + + logger.error(`[${requestId}] Error batch inserting rows`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +} + +/** GET /api/v2/tables/[tableId]/rows — Query rows with filtering, sorting, offset pagination. */ +export const GET = withRouteHandler(async (request: NextRequest, context: TableRowsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2ListTableRowsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const accessResult = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!accessResult.ok) return v2Error('NOT_FOUND', 'Table not found') + + const { table } = accessResult + if (validated.workspaceId !== table.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + // Translate name-keyed filter/sort fields → column ids; translate rows back. + const idByName = buildIdByName(table.schema as TableSchema) + const nameById = buildNameById(table.schema as TableSchema) + const filter = validated.filter + ? filterNamesToIds(validated.filter as Filter, idByName) + : undefined + const sort = validated.sort ? sortNamesToIds(validated.sort, idByName) : undefined + + // Cursor-uniform v2 pagination: the opaque cursor encodes the underlying + // offset (upgradeable to keyset later without an interface change). Total row + // count is intentionally omitted here — it's available as `rowCount` on the table. + const offset = validated.cursor + ? (decodeCursor<{ offset: number }>(validated.cursor)?.offset ?? 0) + : 0 + + const result = await queryRows( + table, + { + filter, + sort, + limit: validated.limit, + offset, + includeTotal: true, + withExecutions: false, + }, + requestId + ) + + const total = result.totalCount ?? 0 + const hasMore = offset + result.rowCount < total + const nextCursor = hasMore ? encodeCursor({ offset: offset + validated.limit }) : null + + return v2CursorList( + result.rows.map((r) => toApiRow(r, nameById)), + nextCursor, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + logger.error(`[${requestId}] Error querying rows`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/tables/[tableId]/rows — Insert row(s). Supports single or batch. */ +export const POST = withRouteHandler( + async (request: NextRequest, context: TableRowsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2CreateTableRowsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + + if ('rows' in parsed.data.body) { + const batchValidated = parsed.data.body + const scopeError = await resolveWorkspaceScope(rateLimit, batchValidated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + return handleBatchInsert(requestId, tableId, batchValidated, userId, rateLimit) + } + + const validated = parsed.data.body + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const accessResult = await checkAccess(tableId, userId, 'write') + if (!accessResult.ok) return v2TableAccessError(accessResult) + + const { table } = accessResult + if (validated.workspaceId !== table.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const idByName = buildIdByName(table.schema as TableSchema) + const nameById = buildNameById(table.schema as TableSchema) + const rowData = rowDataNameToId(validated.data as RowData, idByName) + + const validation = await validateRowData({ + rowData, + schema: table.schema as TableSchema, + tableId, + }) + if (!validation.valid) return v2RowValidationError(validation.response) + + const row = await insertRow( + { tableId, data: rowData, workspaceId: validated.workspaceId, userId }, + table, + requestId + ) + + return v2Data({ row: toApiRow(row, nameById) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + const response = v2RowWriteError(error) + if (response) return response + + logger.error(`[${requestId}] Error inserting row`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +/** PUT /api/v2/tables/[tableId]/rows — Bulk update rows by filter. */ +export const PUT = withRouteHandler(async (request: NextRequest, context: TableRowsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2UpdateRowsByFilterContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const accessResult = await checkAccess(tableId, userId, 'write') + if (!accessResult.ok) return v2TableAccessError(accessResult) + + const { table } = accessResult + if (validated.workspaceId !== table.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const idByName = buildIdByName(table.schema as TableSchema) + const patchData = rowDataNameToId(validated.data as RowData, idByName) + + const sizeValidation = validateRowSize(patchData) + if (!sizeValidation.valid) { + return v2Error('BAD_REQUEST', 'Invalid row data', { details: sizeValidation.errors }) + } + + const result = await updateRowsByFilter( + table, + { + filter: filterNamesToIds(validated.filter as Filter, idByName), + data: patchData, + limit: validated.limit, + actorUserId: userId, + }, + requestId + ) + + // v2 always returns `updatedRowIds` ([] when nothing matched); v1 dropped it + // on the zero-match branch. + return v2Data( + { updatedCount: result.affectedCount, updatedRowIds: result.affectedRowIds }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + const response = v2RowWriteError(error) + if (response) return response + + logger.error(`[${requestId}] Error updating rows by filter`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/tables/[tableId]/rows — Delete rows by filter or IDs. */ +export const DELETE = withRouteHandler( + async (request: NextRequest, context: TableRowsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DeleteTableRowsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const accessResult = await checkAccess(tableId, userId, 'write') + if (!accessResult.ok) return v2TableAccessError(accessResult) + + const { table } = accessResult + if (validated.workspaceId !== table.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + // id-based and filter-based deletes share one envelope; `requestedCount`/ + // `missingRowIds` are populated only for the id-based delete (which has a + // requested set) and omitted for the filter-based delete. + if (validated.rowIds) { + const result = await deleteRowsByIds( + { tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId }, + requestId + ) + + return v2Data( + { + deletedCount: result.deletedCount, + deletedRowIds: result.deletedRowIds, + requestedCount: result.requestedCount, + missingRowIds: result.missingRowIds, + }, + { rateLimit } + ) + } + + const idByName = buildIdByName(table.schema as TableSchema) + const result = await deleteRowsByFilter( + table, + { filter: filterNamesToIds(validated.filter as Filter, idByName), limit: validated.limit }, + requestId + ) + + return v2Data( + { deletedCount: result.affectedCount, deletedRowIds: result.affectedRowIds }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + const response = v2RowWriteError(error) + if (response) return response + + logger.error(`[${requestId}] Error deleting rows`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts new file mode 100644 index 00000000000..0831e5f702b --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts @@ -0,0 +1,98 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2UpsertTableRowContract } from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { RowData, TableSchema } from '@/lib/table' +import { buildIdByName, buildNameById, rowDataNameToId, upsertRow } from '@/lib/table' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiRow, v2TableAccessError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableUpsertAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface UpsertRouteParams { + params: Promise<{ tableId: string }> +} + +/** POST /api/v2/tables/[tableId]/rows/upsert — Insert or update a row based on unique columns. */ +export const POST = withRouteHandler(async (request: NextRequest, context: UpsertRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2UpsertTableRowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const idByName = buildIdByName(table.schema as TableSchema) + const nameById = buildNameById(table.schema as TableSchema) + const upsertResult = await upsertRow( + { + tableId, + workspaceId: validated.workspaceId, + data: rowDataNameToId(validated.data as RowData, idByName), + userId, + conflictTarget: validated.conflictTarget, + }, + table, + requestId + ) + + // v2 includes `position` in the row object (via toApiRow) — v1 dropped it here. + return v2Data( + { row: toApiRow(upsertResult.row, nameById), operation: upsertResult.operation }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + const errorMessage = toError(error).message + if ( + errorMessage.includes('unique column') || + errorMessage.includes('Unique constraint violation') || + errorMessage.includes('conflictTarget') || + errorMessage.includes('row limit') || + errorMessage.includes('Schema validation') || + errorMessage.includes('Upsert requires') || + errorMessage.includes('Row size exceeds') + ) { + return v2Error('BAD_REQUEST', errorMessage) + } + + logger.error(`[${requestId}] Error upserting row`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/route.ts b/apps/sim/app/api/v2/tables/route.ts new file mode 100644 index 00000000000..27fe7418d95 --- /dev/null +++ b/apps/sim/app/api/v2/tables/route.ts @@ -0,0 +1,140 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateTableContract, v2ListTablesContract } from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createTable, getWorkspaceTableLimits, listTables, type TableSchema } from '@/lib/table' +import { normalizeColumn } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiTable } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TablesAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/tables — List all tables in a workspace. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'tables') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2ListTablesContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const tables = await listTables(workspaceId) + const items = tables.map(toApiTable) + + // `listTables` returns the full bounded workspace set → single page. + return v2CursorList(items, null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing tables`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/tables — Create a new table. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'tables') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2CreateTableContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const params = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const planLimits = await getWorkspaceTableLimits(params.workspaceId) + + const normalizedSchema: TableSchema = { + columns: params.schema.columns.map(normalizeColumn), + } + + const table = await createTable( + { + name: params.name, + description: params.description, + schema: normalizedSchema, + workspaceId: params.workspaceId, + userId, + maxTables: planLimits.maxTables, + }, + requestId + ) + + recordAudit({ + workspaceId: params.workspaceId, + actorId: userId, + action: AuditAction.TABLE_CREATED, + resourceType: AuditResourceType.TABLE, + resourceId: table.id, + resourceName: table.name, + description: `Created table "${table.name}" via API`, + metadata: { columnCount: params.schema.columns.length }, + request, + }) + + return v2Data({ table: toApiTable(table) }, { rateLimit, status: 201 }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + if (error instanceof Error) { + if (error.message.includes('maximum table limit')) { + return v2Error('FORBIDDEN', error.message) + } + if ( + error.message.includes('Invalid table name') || + error.message.includes('Invalid schema') || + error.message.includes('already exists') + ) { + return v2Error('BAD_REQUEST', error.message) + } + } + + logger.error(`[${requestId}] Error creating table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts new file mode 100644 index 00000000000..63bd09f8e89 --- /dev/null +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -0,0 +1,103 @@ +import type { NextResponse } from 'next/server' +import type { RowData, TableDefinition, TableSchema } from '@/lib/table' +import { rowDataIdToName } from '@/lib/table' +import { normalizeColumn, rootErrorMessage, rowWriteErrorResponse } from '@/app/api/table/utils' +import { v2Error } from '@/app/api/v2/lib/response' + +/** + * Shared serialization + error helpers for the v2 tables surface. Every v2 + * table/row/column route renders its payloads and access failures through these + * so the public shape, timestamp format, and error envelope stay identical + * across the surface. These reuse the v1 platform services and classifiers — + * only the HTTP envelope is upgraded. + */ + +/** ISO-serializes a `Date | string` timestamp from the table service layer. */ +function toIso(value: Date | string): string { + return value instanceof Date ? value.toISOString() : String(value) +} + +/** + * Normalized public table shape — the same subset of fields the v1 surface + * exposes, with timestamps serialized to ISO strings. Shared by every v2 table + * endpoint so the table payload is identical across the surface. + */ +export function toApiTable(table: TableDefinition) { + return { + id: table.id, + name: table.name, + description: table.description, + schema: { + columns: (table.schema as TableSchema).columns.map(normalizeColumn), + }, + rowCount: table.rowCount, + maxRows: table.maxRows, + createdAt: toIso(table.createdAt), + updatedAt: toIso(table.updatedAt), + } +} + +/** + * Row fields the public API exposes. `data` is stored id-keyed; {@link toApiRow} + * translates it to column names. + */ +interface ApiRowInput { + id: string + data: RowData + position: number + createdAt: Date | string + updatedAt: Date | string +} + +/** + * Normalized public row shape. Callers pass the table's id→name map so `data` is + * keyed by column name (the public contract). `position` is always included — + * every v2 row endpoint, including upsert, exposes it. + */ +export function toApiRow(row: ApiRowInput, nameById: Map) { + return { + id: row.id, + data: rowDataIdToName(row.data, nameById), + position: row.position, + createdAt: toIso(row.createdAt), + updatedAt: toIso(row.updatedAt), + } +} + +/** + * Renders a failed {@link checkAccess} result on a MUTATION path: a missing + * table stays 404, a missing permission stays 403. Read paths instead mask both + * as 404 inline so cross-workspace resource existence is never leaked. + */ +export function v2TableAccessError(result: { ok: false; status: 404 | 403 }): NextResponse { + return result.status === 404 + ? v2Error('NOT_FOUND', 'Table not found') + : v2Error('FORBIDDEN', 'Access denied') +} + +/** + * Maps a known user-facing row-write failure (schema/size/unique/limit) to a v2 + * `BAD_REQUEST`, reusing v1's {@link rowWriteErrorResponse} classifier as the + * single source of truth for which messages are safe to surface. Returns `null` + * for unrecognized errors so the caller logs and returns a generic 500. + */ +export function v2RowWriteError(error: unknown): NextResponse | null { + if (!rowWriteErrorResponse(error)) return null + return v2Error('BAD_REQUEST', rootErrorMessage(error)) +} + +/** + * Adapts a failed-row validation from the shared `validateRowData` / + * `validateBatchRows` helpers — which bake a v1-shaped `{ error, details }` 400 + * response — into the canonical v2 error envelope while preserving the + * structured `details` (per-field / per-row). The validators expose the failure + * only as a rendered response, so the body is read back rather than + * re-implementing the size/schema/unique checks. + */ +export async function v2RowValidationError(response: NextResponse): Promise { + const body = (await response + .clone() + .json() + .catch(() => ({}))) as { error?: string; details?: unknown } + return v2Error('BAD_REQUEST', body.error ?? 'Invalid row data', { details: body.details }) +} diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts new file mode 100644 index 00000000000..87c46b2cd75 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts @@ -0,0 +1,169 @@ +import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v1DeployWorkflowBodySchema } from '@/lib/api/contracts/v1/workflows' +import { + v2DeployWorkflowContract, + v2UndeployWorkflowContract, +} from '@/lib/api/contracts/v2/workflows' +import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowDeployAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 120 + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'workflow-deploy') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DeployWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const rawBody = await parseOptionalJsonBody(request) + if (!rawBody.success) { + return rawBody.response.status === 413 + ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') + : v2Error('BAD_REQUEST', 'Request body must be valid JSON') + } + const body = v1DeployWorkflowBodySchema.safeParse(rawBody.data ?? {}) + if (!body.success) return v2ValidationError(body.error) + + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found') + const { workflow, workspaceId } = target + + await assertWorkflowMutable(id) + + logger.info(`[${requestId}] Deploying workflow ${id} via v2 API`, { userId }) + + const result = await performFullDeploy({ + workflowId: id, + userId, + workflowName: workflow.name || undefined, + versionName: body.data.name, + versionDescription: body.data.description ?? undefined, + requestId, + request, + }) + + if (!result.success) { + const code = + result.errorCode === 'not_found' + ? 'NOT_FOUND' + : result.errorCode === 'validation' + ? 'BAD_REQUEST' + : 'INTERNAL_ERROR' + return v2Error(code, result.error || 'Failed to deploy workflow') + } + + captureServerEvent( + userId, + 'workflow_deployed', + { workflow_id: id, workspace_id: workspaceId }, + { + groups: { workspace: workspaceId }, + setOnce: { first_workflow_deployed_at: new Date().toISOString() }, + } + ) + + return v2Data( + { + id, + isDeployed: true, + deployedAt: result.deployedAt?.toISOString() ?? null, + version: result.version, + warnings: result.warnings ?? [], + }, + { rateLimit } + ) + } catch (error) { + if (error instanceof WorkflowLockedError) { + return v2Error('LOCKED', error.message) + } + logger.error(`[${requestId}] Workflow deploy error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +export const DELETE = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'workflow-deploy') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2UndeployWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found') + const { workflow, workspaceId } = target + + if (!workflow.isDeployed) { + return v2Error('BAD_REQUEST', 'Workflow is not deployed') + } + + await assertWorkflowMutable(id) + + logger.info(`[${requestId}] Undeploying workflow ${id} via v2 API`, { userId }) + + const result = await performFullUndeploy({ workflowId: id, userId, requestId }) + if (!result.success) { + return v2Error('INTERNAL_ERROR', result.error || 'Failed to undeploy workflow') + } + + captureServerEvent( + userId, + 'workflow_undeployed', + { workflow_id: id, workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + + return v2Data( + { + id, + isDeployed: false, + deployedAt: null, + warnings: result.warnings ?? [], + }, + { rateLimit } + ) + } catch (error) { + if (error instanceof WorkflowLockedError) { + return v2Error('LOCKED', error.message) + } + logger.error(`[${requestId}] Workflow undeploy error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts new file mode 100644 index 00000000000..634cf9957cf --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts @@ -0,0 +1,122 @@ +import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v1RollbackWorkflowBodySchema } from '@/lib/api/contracts/v1/workflows' +import { v2RollbackWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { performActivateVersion } from '@/lib/workflows/orchestration' +import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowRollbackAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 120 + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'workflow-rollback') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2RollbackWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const rawBody = await parseOptionalJsonBody(request) + if (!rawBody.success) { + return rawBody.response.status === 413 + ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') + : v2Error('BAD_REQUEST', 'Request body must be valid JSON') + } + const body = v1RollbackWorkflowBodySchema.safeParse(rawBody.data ?? {}) + if (!body.success) return v2ValidationError(body.error) + + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found') + const { workflow, workspaceId } = target + + if (!workflow.isDeployed) { + return v2Error('BAD_REQUEST', 'Workflow is not deployed') + } + + await assertWorkflowMutable(id) + + let targetVersion = body.data.version + if (targetVersion === undefined) { + const previous = await findPreviousDeploymentVersion(id) + if (!previous.ok) { + const message = + previous.reason === 'no_active_version' + ? 'Workflow has no active deployment to roll back from' + : 'No previous deployment version to roll back to' + return v2Error('BAD_REQUEST', message) + } + targetVersion = previous.version + } + + logger.info( + `[${requestId}] Rolling back workflow ${id} to version ${targetVersion} via v2 API`, + { userId } + ) + + const result = await performActivateVersion({ + workflowId: id, + version: targetVersion, + userId, + workflow: workflow as Record, + requestId, + request, + }) + + if (!result.success) { + const code = + result.errorCode === 'not_found' + ? 'NOT_FOUND' + : result.errorCode === 'validation' + ? 'BAD_REQUEST' + : 'INTERNAL_ERROR' + return v2Error(code, result.error || 'Failed to roll back workflow') + } + + captureServerEvent( + userId, + 'deployment_version_activated', + { workflow_id: id, workspace_id: workspaceId, version: targetVersion }, + { groups: { workspace: workspaceId } } + ) + + return v2Data( + { + id, + isDeployed: true, + deployedAt: result.deployedAt?.toISOString() ?? null, + version: targetVersion, + warnings: result.warnings ?? [], + }, + { rateLimit } + ) + } catch (error) { + if (error instanceof WorkflowLockedError) { + return v2Error('LOCKED', error.message) + } + logger.error(`[${requestId}] Workflow rollback error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts new file mode 100644 index 00000000000..a059d669648 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -0,0 +1,81 @@ +import { db } from '@sim/db' +import { workflowBlocks } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2WorkflowDetail, v2GetWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowDetailAPI') + +export const revalidate = 0 + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflow-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2GetWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') + + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') + + const blockRows = await db + .select({ + id: workflowBlocks.id, + type: workflowBlocks.type, + subBlocks: workflowBlocks.subBlocks, + }) + .from(workflowBlocks) + .where(eq(workflowBlocks.workflowId, id)) + + const blocksRecord = Object.fromEntries( + blockRows.map((block) => [block.id, { type: block.type, subBlocks: block.subBlocks }]) + ) + const inputs = extractInputFieldsFromBlocks(blocksRecord) + + const detail: V2WorkflowDetail = { + id: workflowData.id, + name: workflowData.name, + description: workflowData.description, + folderId: workflowData.folderId, + workspaceId: workflowData.workspaceId, + isDeployed: workflowData.isDeployed, + deployedAt: workflowData.deployedAt?.toISOString() ?? null, + runCount: workflowData.runCount, + lastRunAt: workflowData.lastRunAt?.toISOString() ?? null, + variables: (workflowData.variables as Record | null) ?? {}, + inputs, + createdAt: workflowData.createdAt.toISOString(), + updatedAt: workflowData.updatedAt.toISOString(), + } + + return v2Data(detail, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Workflow details fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts new file mode 100644 index 00000000000..a35f045bda7 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -0,0 +1,142 @@ +import { db } from '@sim/db' +import { workflow } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, asc, eq, gt, isNull, or } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2WorkflowListItem, v2ListWorkflowsContract } from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** Keyset cursor for the `(sortOrder, createdAt, id)` ordering. */ +interface WorkflowListCursor { + sortOrder: number + createdAt: string + id: string +} + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2ListWorkflowsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const params = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const conditions = [eq(workflow.workspaceId, params.workspaceId), isNull(workflow.archivedAt)] + + if (params.folderId) { + conditions.push(eq(workflow.folderId, params.folderId)) + } + + if (params.deployedOnly) { + conditions.push(eq(workflow.isDeployed, true)) + } + + if (params.cursor) { + const cursorData = decodeCursor(params.cursor) + if (cursorData) { + const cursorCondition = or( + gt(workflow.sortOrder, cursorData.sortOrder), + and( + eq(workflow.sortOrder, cursorData.sortOrder), + gt(workflow.createdAt, new Date(cursorData.createdAt)) + ), + and( + eq(workflow.sortOrder, cursorData.sortOrder), + eq(workflow.createdAt, new Date(cursorData.createdAt)), + gt(workflow.id, cursorData.id) + ) + ) + if (cursorCondition) { + conditions.push(cursorCondition) + } + } + } + + const rows = await db + .select({ + id: workflow.id, + name: workflow.name, + description: workflow.description, + folderId: workflow.folderId, + workspaceId: workflow.workspaceId, + isDeployed: workflow.isDeployed, + deployedAt: workflow.deployedAt, + runCount: workflow.runCount, + lastRunAt: workflow.lastRunAt, + sortOrder: workflow.sortOrder, + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt, + }) + .from(workflow) + .where(and(...conditions)) + .orderBy(asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)) + .limit(params.limit + 1) + + const hasMore = rows.length > params.limit + const data = rows.slice(0, params.limit) + + let nextCursor: string | null = null + if (hasMore && data.length > 0) { + const last = data[data.length - 1] + nextCursor = encodeCursor({ + sortOrder: last.sortOrder, + createdAt: last.createdAt.toISOString(), + id: last.id, + }) + } + + const formatted: V2WorkflowListItem[] = data.map((w) => ({ + id: w.id, + name: w.name, + description: w.description, + folderId: w.folderId, + workspaceId: w.workspaceId ?? params.workspaceId, + isDeployed: w.isDeployed, + deployedAt: w.deployedAt?.toISOString() ?? null, + runCount: w.runCount, + lastRunAt: w.lastRunAt?.toISOString() ?? null, + createdAt: w.createdAt.toISOString(), + updatedAt: w.updatedAt.toISOString(), + })) + + return v2CursorList(formatted, nextCursor, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Workflows fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/lib/api/contracts/v1/admin/organizations.ts b/apps/sim/lib/api/contracts/v1/admin/organizations.ts index 1281b5e649d..f64fd8da4b4 100644 --- a/apps/sim/lib/api/contracts/v1/admin/organizations.ts +++ b/apps/sim/lib/api/contracts/v1/admin/organizations.ts @@ -142,7 +142,8 @@ const adminV1RemoveOrganizationMemberResultSchema = z.object({ memberId: z.string(), userId: z.string(), billingActions: z.object({ - usageCaptured: z.boolean(), + /** Dollar amount of departed-member usage captured (0 when none). */ + usageCaptured: z.number(), proRestored: z.boolean(), usageRestored: z.boolean(), skipBillingLogic: z.boolean(), @@ -159,8 +160,10 @@ const adminV1TransferOwnershipResultSchema = z.object({ currentOwnerUserId: z.string(), newOwnerUserId: z.string(), workspacesReassigned: z.number(), - billedAccountReassigned: z.boolean(), - overageMigrated: z.boolean(), + /** Count of workspaces whose billed account was reassigned to the new owner. */ + billedAccountReassigned: z.number(), + /** Decimal-string dollar amount of overage migrated to the new owner ('0' when none). */ + overageMigrated: z.string(), billingBlockInherited: z.boolean(), }) diff --git a/apps/sim/lib/api/contracts/v1/audit-logs.ts b/apps/sim/lib/api/contracts/v1/audit-logs.ts index f82b86e4b6d..4ce86e22e9b 100644 --- a/apps/sim/lib/api/contracts/v1/audit-logs.ts +++ b/apps/sim/lib/api/contracts/v1/audit-logs.ts @@ -1,5 +1,11 @@ import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts/types' +import { + adminV1ListResponseSchema, + adminV1PaginationQuerySchema, + adminV1SingleResponseSchema, +} from '@/lib/api/contracts/v1/admin/shared' +import { v1UserLimitsSchema } from '@/lib/api/contracts/v1/shared' const isoDateString = z.string().refine((value) => !Number.isNaN(Date.parse(value)), { error: 'Invalid date format. Use ISO 8601.', @@ -43,25 +49,51 @@ export const v1AdminAuditLogsQuerySchema = z.object({ actorEmail: optionalQueryString, startDate: z.preprocess((value) => (value === '' ? undefined : value), isoDateString.optional()), endDate: z.preprocess((value) => (value === '' ? undefined : value), isoDateString.optional()), + ...adminV1PaginationQuerySchema.shape, }) /** - * Generic wrapper used by v1 admin audit-log responses. The `data` and - * `limits` halves are intentionally `z.unknown()` because this proxy returns - * provider-shaped payloads that vary per route family; tightening here would - * require a discriminated union per route, which is tracked as a follow-up. - * - * boundary-policy: this is the "validates nothing" alias form that the audit - * script's `untyped-response` regex doesn't currently catch. Treat any new - * wrapper of this shape the same way and either annotate at the contract use - * site with `// untyped-response: ` or replace with a concrete schema. + * Public enterprise audit-log entry. Mirrors `formatAuditLogEntry` in + * `app/api/v1/audit-logs/format.ts`; `ipAddress`/`userAgent` are intentionally + * excluded for privacy. `metadata` is genuinely arbitrary per-action JSON. */ -const apiResponseWithLimitsSchema = z - .object({ - data: z.unknown(), - limits: z.unknown().optional(), - }) - .passthrough() +const v1AuditLogEntrySchema = z.object({ + id: z.string(), + workspaceId: z.string().nullable(), + actorId: z.string().nullable(), + actorName: z.string().nullable(), + actorEmail: z.string().nullable(), + action: z.string(), + resourceType: z.string(), + resourceId: z.string().nullable(), + resourceName: z.string().nullable(), + description: z.string().nullable(), + metadata: z.unknown(), + createdAt: z.string(), +}) + +/** + * Admin audit-log entry. Mirrors `toAdminAuditLog` in `app/api/v1/admin/types.ts`, + * which additionally exposes `ipAddress`/`userAgent`. + */ +const adminV1AuditLogEntrySchema = v1AuditLogEntrySchema.extend({ + ipAddress: z.string().nullable(), + userAgent: z.string().nullable(), +}) + +const v1ListAuditLogsResponseSchema = z.object({ + data: z.array(v1AuditLogEntrySchema), + nextCursor: z.string().optional(), + limits: v1UserLimitsSchema, +}) + +const v1GetAuditLogResponseSchema = z.object({ + data: v1AuditLogEntrySchema, + limits: v1UserLimitsSchema, +}) + +export type V1AuditLogEntry = z.output +export type AdminV1AuditLogEntry = z.output export const v1ListAuditLogsContract = defineRouteContract({ method: 'GET', @@ -69,7 +101,7 @@ export const v1ListAuditLogsContract = defineRouteContract({ query: v1ListAuditLogsQuerySchema, response: { mode: 'json', - schema: apiResponseWithLimitsSchema, + schema: v1ListAuditLogsResponseSchema, }, }) @@ -79,7 +111,7 @@ export const v1GetAuditLogContract = defineRouteContract({ params: v1AuditLogParamsSchema, response: { mode: 'json', - schema: apiResponseWithLimitsSchema, + schema: v1GetAuditLogResponseSchema, }, }) @@ -89,7 +121,7 @@ export const v1AdminListAuditLogsContract = defineRouteContract({ query: v1AdminAuditLogsQuerySchema, response: { mode: 'json', - schema: apiResponseWithLimitsSchema, + schema: adminV1ListResponseSchema(adminV1AuditLogEntrySchema), }, }) @@ -99,6 +131,6 @@ export const v1AdminGetAuditLogContract = defineRouteContract({ params: v1AuditLogParamsSchema, response: { mode: 'json', - schema: apiResponseWithLimitsSchema, + schema: adminV1SingleResponseSchema(adminV1AuditLogEntrySchema), }, }) diff --git a/apps/sim/lib/api/contracts/v1/shared.ts b/apps/sim/lib/api/contracts/v1/shared.ts new file mode 100644 index 00000000000..9502e57ee5f --- /dev/null +++ b/apps/sim/lib/api/contracts/v1/shared.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' + +/** + * Rate-limit / usage envelope injected into every Family-A v1 response by + * `createApiResponse` (see `app/api/v1/logs/meta.ts`). Mirrors the `UserLimits` + * interface in that file. Shared here so logs, audit-logs, and workflows + * contracts describe `limits` identically instead of each redefining it. + */ +export const v1UserLimitsSchema = z.object({ + workflowExecutionRateLimit: z.object({ + sync: z.object({ + requestsPerMinute: z.number(), + maxBurst: z.number(), + remaining: z.number(), + resetAt: z.string(), + }), + async: z.object({ + requestsPerMinute: z.number(), + maxBurst: z.number(), + remaining: z.number(), + resetAt: z.string(), + }), + }), + usage: z.object({ + currentPeriodCost: z.number(), + limit: z.number(), + plan: z.string(), + isExceeded: z.boolean(), + }), +}) + +export type V1UserLimits = z.output + +/** + * Family-A envelope helper: `{ data, limits }`. Use for the `createApiResponse` + * detail/action surfaces (logs/[id], workflows deploy/rollback/undeploy). List + * endpoints that also return a `nextCursor` should compose the object directly + * (`{ data, nextCursor: z.string().optional(), limits: v1UserLimitsSchema }`). + */ +export const withV1Limits = (dataSchema: T) => + z.object({ + data: dataSchema, + limits: v1UserLimitsSchema, + }) diff --git a/apps/sim/lib/api/contracts/v2/audit-logs.ts b/apps/sim/lib/api/contracts/v2/audit-logs.ts new file mode 100644 index 00000000000..1084d9bbecb --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/audit-logs.ts @@ -0,0 +1,58 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v1AuditLogParamsSchema, + v1ListAuditLogsQuerySchema, +} from '@/lib/api/contracts/v1/audit-logs' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 audit-logs contracts. These are org-scoped enterprise endpoints. The + * request schemas are reused verbatim from v1 (the query/param shape is + * unchanged); only the response envelope is upgraded to the canonical v2 + * shapes. The v1 `limits` body is dropped — usage limits live on the dedicated + * usage endpoint, not inlined into every response. + */ + +/** + * Public enterprise audit-log entry. Mirrors `formatAuditLogEntry` in + * `app/api/v1/audit-logs/format.ts` and the v1 `v1AuditLogEntrySchema`; + * `ipAddress`/`userAgent` are intentionally excluded for privacy. `metadata` is + * genuinely arbitrary per-action JSON. + */ +export const v2AuditLogEntrySchema = z.object({ + id: z.string(), + workspaceId: z.string().nullable(), + actorId: z.string().nullable(), + actorName: z.string().nullable(), + actorEmail: z.string().nullable(), + action: z.string(), + resourceType: z.string(), + resourceId: z.string().nullable(), + resourceName: z.string().nullable(), + description: z.string().nullable(), + metadata: z.unknown(), + createdAt: z.string(), +}) + +export type V2AuditLogEntry = z.output + +export const v2ListAuditLogsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/audit-logs', + query: v1ListAuditLogsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2AuditLogEntrySchema), + }, +}) + +export const v2GetAuditLogContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/audit-logs/[id]', + params: v1AuditLogParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2AuditLogEntrySchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts new file mode 100644 index 00000000000..040ffa4dc80 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -0,0 +1,112 @@ +import { z } from 'zod' +import { workspaceFileIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 files contracts. v2 drops the v1 `{ success, data, limits }` envelope in + * favor of the canonical v2 shapes (`{ data }` / `{ data, nextCursor }`) and + * adds cursor pagination to the list. The workspace is always carried as a query + * param — including on upload — so the route can authorize before reading the + * multipart body. + */ + +/** A workspace file as exposed by the v2 surface. */ +export const v2FileSchema = z.object({ + id: z.string(), + name: z.string(), + size: z.number().nonnegative(), + type: z.string(), + key: z.string(), + uploadedBy: z.string(), + /** ISO-8601 timestamp. */ + uploadedAt: z.string(), +}) + +export type V2File = z.output + +/** Acknowledgement returned by a successful archive (soft delete). */ +export const v2DeleteFileResultSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) + +export type V2DeleteFileResult = z.output + +export const v2FileParamsSchema = z.object({ + fileId: workspaceFileIdSchema, +}) + +export type V2FileParams = z.output + +/** + * List query: workspace scope plus opaque keyset cursor pagination keyed on + * `(uploadedAt, id)`. `limit` clamps to `[1, 1000]` (default 100) to bound the + * response. The cursor is the base64-JSON codec shared across the v2 surface. + */ +export const v2ListFilesQuerySchema = z.object({ + workspaceId: workspaceIdSchema, + limit: z.coerce + .number() + .optional() + .default(100) + .transform((v) => Math.min(Math.max(1, Math.trunc(v)), 1000)), + cursor: z.string().min(1).optional(), +}) + +export type V2ListFilesQuery = z.output + +/** Upload carries the workspace as a query param so auth runs before buffering. */ +export const v2UploadFileQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) + +export type V2UploadFileQuery = z.output + +/** Download/delete both target a single file within a workspace-scoped query. */ +export const v2FileWorkspaceQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) + +export type V2FileWorkspaceQuery = z.output + +export const v2ListFilesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files', + query: v2ListFilesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2FileSchema), + }, +}) + +export const v2UploadFileContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files', + query: v2UploadFileQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FileSchema), + }, +}) + +export const v2DownloadFileContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files/[fileId]', + params: v2FileParamsSchema, + query: v2FileWorkspaceQuerySchema, + response: { + mode: 'binary', + }, +}) + +export const v2DeleteFileContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/files/[fileId]', + params: v2FileParamsSchema, + query: v2FileWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteFileResultSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts new file mode 100644 index 00000000000..06f4064d2fb --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -0,0 +1,270 @@ +import { z } from 'zod' +import { knowledgeBaseDataSchema } from '@/lib/api/contracts/knowledge/base' +import { documentDataSchema } from '@/lib/api/contracts/knowledge/documents' +import { + knowledgeBaseParamsSchema, + knowledgeDocumentParamsSchema, + nullableWireDateSchema, +} from '@/lib/api/contracts/knowledge/shared' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v1CreateKnowledgeBaseBodySchema, + v1KnowledgeSearchBodySchema, + v1KnowledgeWorkspaceQuerySchema, + v1ListKnowledgeBasesQuerySchema, + v1ListKnowledgeDocumentsQuerySchema, + v1UpdateKnowledgeBaseBodySchema, +} from '@/lib/api/contracts/v1/knowledge' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 knowledge contracts. + * + * Request shapes (params/query/body) are reused verbatim from the v1 public + * contract (`@/lib/api/contracts/v1/knowledge`) — the public request surface is + * unchanged. Only the response envelope is upgraded to the canonical v2 shapes + * (`{ data }` for single/mutation, `{ data, pagination }` for the offset-paginated + * document list), and the success `message` strings v1 inlined are dropped. + * + * The concrete `data` item schemas reuse the first-party knowledge data schemas + * as their source of truth: the knowledge-base item is a `.pick()` of + * {@link knowledgeBaseDataSchema} matching `formatKnowledgeBase`'s projection, + * and the document items reuse the core fields of {@link documentDataSchema}. The + * v2 (and v1-public) document projection renames `uploadedAt` to `createdAt` and + * omits `fileUrl`/tag slots, so that rename is layered on via `.extend()`. + */ + +/** + * Knowledge-base item — the exact subset `formatKnowledgeBase` projects from a + * {@link KnowledgeBaseWithCounts}. `userId`, `workspaceId`, and `deletedAt` are + * intentionally not exposed on the public surface. + */ +export const v2KnowledgeBaseSchema = knowledgeBaseDataSchema.pick({ + id: true, + name: true, + description: true, + tokenCount: true, + embeddingModel: true, + embeddingDimension: true, + chunkingConfig: true, + docCount: true, + connectorTypes: true, + createdAt: true, + updatedAt: true, +}) +export type V2KnowledgeBase = z.output + +/** `{ knowledgeBase }` payload for single-KB reads and mutations. */ +export const v2KnowledgeBaseDataSchema = z.object({ knowledgeBase: v2KnowledgeBaseSchema }) +export type V2KnowledgeBaseData = z.output + +/** Delete acknowledgement — the id of the resource that was deleted. */ +export const v2KnowledgeDeleteDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) +export type V2KnowledgeDeleteData = z.output + +/** + * Document core fields shared by the list item and the detail payload, reused + * from the first-party {@link documentDataSchema}. + */ +const v2KnowledgeDocumentCoreSchema = documentDataSchema.pick({ + id: true, + knowledgeBaseId: true, + filename: true, + fileSize: true, + mimeType: true, + processingStatus: true, + chunkCount: true, + tokenCount: true, + characterCount: true, + enabled: true, +}) + +/** + * Document list item / upload acknowledgement. `createdAt` is the public rename + * of the underlying `uploadedAt` column. + */ +export const v2KnowledgeDocumentSummarySchema = v2KnowledgeDocumentCoreSchema.extend({ + createdAt: nullableWireDateSchema, +}) +export type V2KnowledgeDocumentSummary = z.output + +/** + * Document detail — the summary plus processing state and connector provenance. + * Every field is always present (nullable), mirroring the v1 detail projection. + */ +export const v2KnowledgeDocumentSchema = v2KnowledgeDocumentSummarySchema.extend({ + processingError: z.string().nullable(), + processingStartedAt: nullableWireDateSchema, + processingCompletedAt: nullableWireDateSchema, + connectorId: z.string().nullable(), + connectorType: z.string().nullable(), + sourceUrl: z.string().nullable(), +}) +export type V2KnowledgeDocument = z.output + +/** `{ document }` payload for the upload acknowledgement (summary shape). */ +export const v2KnowledgeDocumentSummaryDataSchema = z.object({ + document: v2KnowledgeDocumentSummarySchema, +}) +export type V2KnowledgeDocumentSummaryData = z.output + +/** `{ document }` payload for the document detail read. */ +export const v2KnowledgeDocumentDataSchema = z.object({ document: v2KnowledgeDocumentSchema }) +export type V2KnowledgeDocumentData = z.output + +/** + * A single vector/tag search hit. `metadata` is the document's display-named tag + * map; values are user-defined and of mixed type (string/number/boolean/date), + * so they are carried as `unknown` and serialized as-is. + */ +export const v2KnowledgeSearchResultSchema = z.object({ + documentId: z.string(), + documentName: z.string().nullable(), + sourceUrl: z.string().nullable(), + content: z.string(), + chunkIndex: z.number(), + metadata: z.record(z.string(), z.unknown()), + similarity: z.number(), +}) +export type V2KnowledgeSearchResult = z.output + +/** Search response payload — mirrors the v1 `data` object. */ +export const v2KnowledgeSearchDataSchema = z.object({ + results: z.array(v2KnowledgeSearchResultSchema), + query: z.string(), + knowledgeBaseIds: z.array(z.string()), + topK: z.number(), + totalResults: z.number(), +}) +export type V2KnowledgeSearchData = z.output + +/** Upload carries the workspace as a query param so auth runs before the multipart body is buffered. */ +export const v2UploadKnowledgeDocumentQuerySchema = z.object({ workspaceId: workspaceIdSchema }) +export type V2UploadKnowledgeDocumentQuery = z.output + +/** + * KB list. `getKnowledgeBases` returns the full workspace set (a small, bounded + * per-workspace list), so today the cursor list is a single full page + * (`nextCursor` always `null`). The canonical cursor envelope keeps the v2 list + * surface uniform; real pagination can be added later behind the opaque cursor. + */ +export const v2ListKnowledgeBasesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge', + query: v1ListKnowledgeBasesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2KnowledgeBaseSchema), + }, +}) + +export const v2CreateKnowledgeBaseContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge', + body: v1CreateKnowledgeBaseBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeBaseDataSchema), + }, +}) + +export const v2GetKnowledgeBaseContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge/[id]', + params: knowledgeBaseParamsSchema, + query: v1KnowledgeWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeBaseDataSchema), + }, +}) + +export const v2UpdateKnowledgeBaseContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/knowledge/[id]', + params: knowledgeBaseParamsSchema, + body: v1UpdateKnowledgeBaseBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeBaseDataSchema), + }, +}) + +export const v2DeleteKnowledgeBaseContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/knowledge/[id]', + params: knowledgeBaseParamsSchema, + query: v1KnowledgeWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeDeleteDataSchema), + }, +}) + +export const v2SearchKnowledgeContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge/search', + body: v1KnowledgeSearchBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeSearchDataSchema), + }, +}) + +/** + * Document list query: the v1 search/filter/sort/limit shape with `offset` + * swapped for an opaque `cursor`. Total doc count is available as `docCount` on + * the knowledge base. + */ +export const v2ListKnowledgeDocumentsQuerySchema = v1ListKnowledgeDocumentsQuerySchema + .omit({ offset: true }) + .extend({ cursor: z.string().min(1).optional() }) +export type V2ListKnowledgeDocumentsQuery = z.output + +export const v2ListKnowledgeDocumentsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge/[id]/documents', + params: knowledgeBaseParamsSchema, + query: v2ListKnowledgeDocumentsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2KnowledgeDocumentSummarySchema), + }, +}) + +export const v2UploadKnowledgeDocumentContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge/[id]/documents', + params: knowledgeBaseParamsSchema, + query: v2UploadKnowledgeDocumentQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeDocumentSummaryDataSchema), + }, +}) + +export const v2GetKnowledgeDocumentContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge/[id]/documents/[documentId]', + params: knowledgeDocumentParamsSchema, + query: v1KnowledgeWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeDocumentDataSchema), + }, +}) + +export const v2DeleteKnowledgeDocumentContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/knowledge/[id]/documents/[documentId]', + params: knowledgeDocumentParamsSchema, + query: v1KnowledgeWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts new file mode 100644 index 00000000000..774aceb8794 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -0,0 +1,123 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v1ExecutionParamsSchema, + v1ListLogsQuerySchema, + v1LogParamsSchema, +} from '@/lib/api/contracts/v1/logs' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 logs contracts. The query schemas are reused verbatim from v1 (the request + * shape is unchanged); only the response envelope is upgraded to the canonical + * v2 shapes with concrete item schemas. + */ + +const v2LogCostSchema = z.object({ total: z.number() }).nullable() + +/** Execution `files` is a per-run jsonb array of attachment metadata. */ +const v2LogFilesSchema = z.array(z.unknown()).nullable() + +const v2LogWorkflowSummarySchema = z.object({ + id: z.string().nullable(), + name: z.string(), + description: z.string().nullable(), + deleted: z.boolean(), +}) + +export const v2LogListItemSchema = z.object({ + id: z.string(), + workflowId: z.string().nullable(), + executionId: z.string(), + deploymentVersionId: z.string().nullable(), + level: z.string(), + trigger: z.string(), + startedAt: z.string(), + endedAt: z.string().nullable(), + totalDurationMs: z.number().nullable(), + cost: v2LogCostSchema, + files: v2LogFilesSchema, + /** Present only when `details=full`. */ + workflow: v2LogWorkflowSummarySchema.optional(), + /** Present only when `details=full` and `includeFinalOutput=true`. */ + finalOutput: z.unknown().optional(), + /** Present only when `details=full` and `includeTraceSpans=true`. */ + traceSpans: z.unknown().optional(), +}) + +export type V2LogListItem = z.output + +export const v2LogDetailSchema = z.object({ + id: z.string(), + workflowId: z.string().nullable(), + executionId: z.string(), + level: z.string(), + trigger: z.string(), + startedAt: z.string(), + endedAt: z.string().nullable(), + totalDurationMs: z.number().nullable(), + files: v2LogFilesSchema, + workflow: z.object({ + id: z.string().nullable(), + name: z.string(), + description: z.string().nullable(), + folderId: z.string().nullable(), + userId: z.string().nullable(), + workspaceId: z.string().nullable(), + createdAt: z.string().nullable(), + updatedAt: z.string().nullable(), + deleted: z.boolean(), + }), + /** Materialized execution trace (block states, trace spans). */ + executionData: z.unknown(), + cost: v2LogCostSchema, + createdAt: z.string(), +}) + +export type V2LogDetail = z.output + +export const v2ExecutionSchema = z.object({ + executionId: z.string(), + workflowId: z.string().nullable(), + /** Workflow state snapshot at execution time. */ + workflowState: z.unknown(), + executionMetadata: z.object({ + trigger: z.string(), + startedAt: z.string(), + endedAt: z.string().nullable(), + totalDurationMs: z.number().nullable(), + cost: v2LogCostSchema, + }), +}) + +export type V2Execution = z.output + +export const v2ListLogsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/logs', + query: v1ListLogsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2LogListItemSchema), + }, +}) + +export const v2GetLogContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/logs/[id]', + params: v1LogParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2LogDetailSchema), + }, +}) + +export const v2GetExecutionContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/logs/executions/[executionId]', + params: v1ExecutionParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ExecutionSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts new file mode 100644 index 00000000000..d0579054727 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -0,0 +1,39 @@ +import { z } from 'zod' + +/** + * Shared building blocks for the v2 API contract surface. + * + * v2 standardizes on a single response family across every endpoint: + * - single resource: `{ data: T }` + * - list: `{ data: T[], nextCursor: string | null }` + * - error: `{ error: { code, message, details? } }` + * + * Every list uses the opaque-cursor envelope (Stripe/Slack-style): `limit` + + * `cursor` in, `{ data, nextCursor }` out. Cursors are opaque so the underlying + * scheme (keyset / offset / full-set) can change without a contract change. + * Total counts are not returned on lists — they're available on the parent + * resource where relevant (e.g. `rowCount` on a table, `docCount` on a KB). + * + * Rate-limit state is carried in `X-RateLimit-*` response headers (not the + * body). Usage limits are available from the dedicated usage endpoint rather + * than being inlined into every response. + */ + +/** Canonical v2 error envelope. */ +export const v2ErrorResponseSchema = z.object({ + error: z.object({ + code: z.string(), + message: z.string(), + details: z.unknown().optional(), + }), +}) + +/** `{ data: T }` */ +export const v2DataResponse = (dataSchema: T) => z.object({ data: dataSchema }) + +/** `{ data: T[], nextCursor: string | null }` — the v2 list envelope. */ +export const v2CursorListResponse = (itemSchema: T) => + z.object({ + data: z.array(itemSchema), + nextCursor: z.string().nullable(), + }) diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts new file mode 100644 index 00000000000..4fa64291fe6 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -0,0 +1,321 @@ +import { z } from 'zod' +import { + createTableColumnBodySchema, + deleteTableColumnBodySchema, + deleteTableRowsBodySchema, + tableColumnSchema, + tableIdParamsSchema, + tableRowParamsSchema, + updateRowsByFilterBodySchema, + updateTableColumnBodySchema, + updateTableRowBodySchema, + upsertTableRowBodySchema, +} from '@/lib/api/contracts/tables' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v1CreateTableBodySchema, + v1CreateTableRowsBodySchema, + v1ListTablesQuerySchema, + v1TableRowsQuerySchema, +} from '@/lib/api/contracts/v1/tables' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 tables contracts. + * + * Request shapes (params/query/body) are reused verbatim from the v1 contract + * and the first-party `/api/table` contract — the public table request surface + * is unchanged. Only the response envelope is upgraded to the canonical v2 + * shapes (`{ data }` for single/mutation, `{ data, pagination }` for the + * list/offset surfaces), and the outcome-dependent payloads are made consistent + * (see per-contract notes below). + * + * The `data` item schemas are concrete and describe exactly what the route's + * `toApiTable`/`toApiRow` serializers emit. The first-party + * `tableDefinitionSchema`/`tableRowSchema` are NOT reused here because they are + * opaque (`z.custom`) and their inferred types include fields the public wire + * never carries (`executions`, `workspaceId`, `Date` timestamps, …). Column + * shape is reused from the concrete first-party `tableColumnSchema`. + */ + +/** + * Public table shape emitted by `toApiTable` (timestamps ISO-serialized). + * Concrete so the v2 contract describes exactly what the wire carries. + */ +export const v2ApiTableSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + schema: z.object({ columns: z.array(tableColumnSchema) }), + rowCount: z.number(), + maxRows: z.number(), + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2ApiTable = z.output + +/** + * Public row shape emitted by `toApiRow`. `data` is keyed by column NAME (the + * id→name translation the route applies); cell values are user-defined, so the + * map is `Record`. Timestamps ISO. + */ +export const v2ApiRowSchema = z.object({ + id: z.string(), + data: z.record(z.string(), z.unknown()), + position: z.number(), + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2ApiRow = z.output + +/** A single table definition payload. */ +export const v2TableDataSchema = z.object({ table: v2ApiTableSchema }) +export type V2TableData = z.output + +/** Archive confirmation — the id of the table that was archived. */ +export const v2DeleteTableDataSchema = z.object({ id: z.string() }) +export type V2DeleteTableData = z.output + +/** The table's full column list after a column mutation. */ +export const v2TableColumnsDataSchema = z.object({ columns: z.array(tableColumnSchema) }) +export type V2TableColumnsData = z.output + +/** A single row payload. */ +export const v2TableRowDataSchema = z.object({ row: v2ApiRowSchema }) +export type V2TableRowData = z.output + +/** Batch-insert payload. */ +export const v2BatchInsertRowsDataSchema = z.object({ + rows: z.array(v2ApiRowSchema), + insertedCount: z.number(), +}) +export type V2BatchInsertRowsData = z.output + +/** + * Bulk update-by-filter payload. v2 always returns `updatedRowIds` (`[]` when + * nothing matched) — v1 dropped the field on the zero-match branch. + */ +export const v2UpdateRowsDataSchema = z.object({ + updatedCount: z.number(), + updatedRowIds: z.array(z.string()), +}) +export type V2UpdateRowsData = z.output + +/** + * Bulk delete payload — one consistent shape for both id-based and + * filter-based deletes. `requestedCount`/`missingRowIds` are populated for the + * id-based delete (which has a requested set) and omitted for the filter-based + * delete; v1 emitted two divergent shapes here. + */ +export const v2DeleteRowsDataSchema = z.object({ + deletedCount: z.number(), + deletedRowIds: z.array(z.string()), + requestedCount: z.number().optional(), + missingRowIds: z.array(z.string()).optional(), +}) +export type V2DeleteRowsData = z.output + +/** Single-row delete payload — mirrors the bulk shape's required fields. */ +export const v2DeleteRowDataSchema = z.object({ + deletedCount: z.number(), + deletedRowIds: z.array(z.string()), +}) +export type V2DeleteRowData = z.output + +/** Upsert payload — the row object includes `position` like every other row endpoint. */ +export const v2UpsertRowDataSchema = z.object({ + row: v2ApiRowSchema, + operation: z.enum(['insert', 'update']), +}) +export type V2UpsertRowData = z.output + +/** + * Table list. `listTables` returns every table in the workspace (a small, + * bounded per-workspace set), so today the cursor list is a single full page + * (`nextCursor` is always `null`). Using the canonical cursor envelope keeps the + * whole v2 list surface uniform, and real pagination can be added later behind + * the opaque cursor without an interface change. + */ +export const v2ListTablesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables', + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2ApiTableSchema), + }, +}) + +export const v2CreateTableContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables', + body: v1CreateTableBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableDataSchema), + }, +}) + +export const v2GetTableContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]', + params: tableIdParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableDataSchema), + }, +}) + +export const v2DeleteTableContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/[tableId]', + params: tableIdParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteTableDataSchema), + }, +}) + +export const v2AddTableColumnContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/columns', + params: tableIdParamsSchema, + body: createTableColumnBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableColumnsDataSchema), + }, +}) + +export const v2UpdateTableColumnContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/tables/[tableId]/columns', + params: tableIdParamsSchema, + body: updateTableColumnBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableColumnsDataSchema), + }, +}) + +export const v2DeleteTableColumnContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/[tableId]/columns', + params: tableIdParamsSchema, + body: deleteTableColumnBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableColumnsDataSchema), + }, +}) + +/** + * Row list query: the v1 filter/sort/limit request shape with `offset` swapped + * for an opaque `cursor` (cursor-uniform v2 pagination). The cursor encodes the + * underlying offset today; it can move to a keyset implementation later without + * an interface change. Total row count is available as `rowCount` on the table. + */ +export const v2TableRowsQuerySchema = v1TableRowsQuerySchema.omit({ offset: true }).extend({ + cursor: z.string().min(1).optional(), +}) +export type V2TableRowsQuery = z.output + +/** Cursor-paginated row list. */ +export const v2ListTableRowsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/rows', + params: tableIdParamsSchema, + query: v2TableRowsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2ApiRowSchema), + }, +}) + +/** + * Single contract for `POST /rows` — the body is the single|batch union so the + * route can dispatch in one `parseRequest`, and the response is the matching + * union (`{ data: { row } }` for a single insert, `{ data: { rows, + * insertedCount } }` for a batch). + */ +export const v2CreateTableRowsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/rows', + params: tableIdParamsSchema, + body: v1CreateTableRowsBodySchema, + response: { + mode: 'json', + schema: z.union([ + v2DataResponse(v2TableRowDataSchema), + v2DataResponse(v2BatchInsertRowsDataSchema), + ]), + }, +}) + +export const v2UpdateRowsByFilterContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/tables/[tableId]/rows', + params: tableIdParamsSchema, + body: updateRowsByFilterBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2UpdateRowsDataSchema), + }, +}) + +export const v2DeleteTableRowsContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/[tableId]/rows', + params: tableIdParamsSchema, + body: deleteTableRowsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteRowsDataSchema), + }, +}) + +export const v2GetTableRowContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/rows/[rowId]', + params: tableRowParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableRowDataSchema), + }, +}) + +export const v2UpdateTableRowContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/tables/[tableId]/rows/[rowId]', + params: tableRowParamsSchema, + body: updateTableRowBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableRowDataSchema), + }, +}) + +export const v2DeleteTableRowContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/[tableId]/rows/[rowId]', + params: tableRowParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteRowDataSchema), + }, +}) + +export const v2UpsertTableRowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/rows/upsert', + params: tableIdParamsSchema, + body: upsertTableRowBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2UpsertRowDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts new file mode 100644 index 00000000000..05ca4a36fe8 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -0,0 +1,112 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v1DeployWorkflowDataSchema, + v1ListWorkflowsQuerySchema, + v1RollbackWorkflowDataSchema, +} from '@/lib/api/contracts/v1/workflows' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { workflowIdParamsSchema } from '@/lib/api/contracts/workflows' + +/** + * v2 workflows contracts. Request shapes are reused verbatim from v1 (the list + * query and `[id]` param are unchanged); only the response envelope is upgraded + * to the canonical v2 shapes with concrete item/detail schemas. The + * deploy/rollback/undeploy data payloads reuse the already-concrete v1 schemas, + * re-wrapped in `v2DataResponse` (the v1 `limits` body field is dropped — v2 + * carries rate-limit state in headers and usage on a dedicated endpoint). + */ + +export const v2WorkflowListItemSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + folderId: z.string().nullable(), + workspaceId: z.string(), + isDeployed: z.boolean(), + deployedAt: z.string().nullable(), + runCount: z.number(), + lastRunAt: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}) + +export type V2WorkflowListItem = z.output + +/** A single trigger input field extracted from the workflow's input-definition block. */ +const v2WorkflowInputFieldSchema = z.object({ + name: z.string(), + type: z.string(), + description: z.string().optional(), +}) + +export const v2WorkflowDetailSchema = v2WorkflowListItemSchema.extend({ + /** + * Workflow-scoped variables keyed by variable id. Each value is a structured + * variable object (`{ id, name, type, value, ... }`); only the inner `value` + * is user-defined/free-form. Kept as `unknown` to tolerate legacy/unstamped + * rows — tightening to a concrete object schema later is consumer-safe (the + * wire already carries the full object), so it stays additively evolvable. + */ + variables: z.record(z.string(), z.unknown()), + inputs: z.array(v2WorkflowInputFieldSchema), +}) + +export type V2WorkflowDetail = z.output + +/** + * Undeploy returns the deployment state without a version number. Derived from + * the exported v1 deploy data schema (its private base is not exported) so the + * shape stays in lockstep with v1. + */ +const v2UndeployWorkflowDataSchema = v1DeployWorkflowDataSchema.omit({ version: true }) + +export const v2ListWorkflowsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows', + query: v1ListWorkflowsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2WorkflowListItemSchema), + }, +}) + +export const v2GetWorkflowContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[id]', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowDetailSchema), + }, +}) + +export const v2DeployWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[id]/deploy', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v1DeployWorkflowDataSchema), + }, +}) + +export const v2UndeployWorkflowContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/workflows/[id]/deploy', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2UndeployWorkflowDataSchema), + }, +}) + +export const v2RollbackWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[id]/rollback', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v1RollbackWorkflowDataSchema), + }, +}) diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts index 30379de8031..4d352d041a5 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts @@ -40,6 +40,12 @@ export interface PerformDeleteWorkspaceFileItemsParams { userId: string fileIds?: string[] folderIds?: string[] + /** + * Optional originating request, forwarded to the audit log so the deletion + * entry captures client IP / user agent. Omitted by in-app callers that have + * no HTTP request in scope. + */ + request?: { headers: { get(name: string): string | null } } } export interface PerformDeleteWorkspaceFileItemsResult { @@ -137,7 +143,7 @@ export interface PerformRestoreWorkspaceFileFolderResult { export async function performDeleteWorkspaceFileItems( params: PerformDeleteWorkspaceFileItemsParams ): Promise { - const { workspaceId, userId, fileIds = [], folderIds = [] } = params + const { workspaceId, userId, fileIds = [], folderIds = [], request } = params if (fileIds.length === 0 && folderIds.length === 0) { return { @@ -172,6 +178,7 @@ export async function performDeleteWorkspaceFileItems( resourceType: AuditResourceType.FILE, description: `Deleted ${fileIds.length} file${fileIds.length === 1 ? '' : 's'}`, metadata: { fileIds }, + request, }) } @@ -190,6 +197,7 @@ export async function performDeleteWorkspaceFileItems( folders: deletedItems.folders, }, }, + request, }) } diff --git a/bun.lock b/bun.lock index 2b92202f9ae..a7d73045fb9 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "simstudio", @@ -2952,7 +2951,7 @@ "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], - "lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], + "lucide-react": ["lucide-react@1.18.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -4306,6 +4305,8 @@ "@shuding/opentype.js/fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], + "@sim/emcn/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], + "@sim/realtime/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], "@sim/runtime-secrets/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], @@ -4496,16 +4497,12 @@ "fumadocs-openapi/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - "fumadocs-openapi/lucide-react": ["lucide-react@1.18.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA=="], - "fumadocs-openapi/shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="], "fumadocs-openapi/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "fumadocs-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], - "fumadocs-ui/lucide-react": ["lucide-react@1.18.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA=="], - "fumadocs-ui/shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="], "fumadocs-ui/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], @@ -4658,6 +4655,8 @@ "sim/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], + "sim/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], + "sim/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], "simstudio/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 680dfa03bf1..74074d6c1c0 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 872, - zodRoutes: 872, + totalRoutes: 887, + zodRoutes: 887, nonZodRoutes: 0, } as const